Tekninen artikkeli

Peruutettava progressiivinen PDF-renderöinti Delphissä (PDFium)

Useimmat PDF-sivut rasteroidaan muutamassa millisekunnissa, etkä ajattele asiaa lainkaan. Sitten käyttäjä avaa A1-kokoisen teknisen piirustuksen, sivun jossa on kymmeniä tuhansia vektoriviivoja, tai julisteen joka on täynnä läpinäkyvyysryhmiä ja pehmeitä maskeja, ja yksittäinen maalauskutsu kestää kaksi tai kolme sekuntia. Jos kutsu ajetaan käyttöliittymäsäikeessä, ikkuna lakkaa piirtymästä, otsikkopalkki harmaantuu ja käyttöjärjestelmä tarjoaa sovelluksen tappamista. Työ on sinänsä perusteltua. Sivu todella vaatii sen ajan. Vika on siinä, että renderöinti on yksi jakamaton estävä kutsu, jolla ei voi hengähtää välillä eikä jota voi pysäyttää

Tämä artikkeli käsittelee täsmälleen toista näistä kahdesta ongelmasta: pitkän yksittäisen sivun renderöinnin peruuttamista ilman käyttöliittymän jäätymistä. Käyttäjä napsautti seuraavaa sivua, zoomasi tai sulki asiakirjan, ja käynnissä oleva renderöinti on nyt turhaa työtä, jonka pitäisi päättyä seuraavassa mahdollisessa kohdassa eikä jatkua loppuun asti. Vierityksen ja zoomauksen pehmentäminen välimuistiin tallennettujen rasterointitulosten avulla on eri kysymys ja vaatii oman suunnittelunsa; sitä käsitellään lopussa linkitetyssä sisarartikkelissa. Tässä ainoa kysymys on, miten yksi progressiivinen renderöinti saadaan vastaamaan peruutuspyyntöön nopeasti ja siististi

PDFiumin jo tarjoama progressiivinen renderöinti-API

PDFium ennakoi ongelman käyttöliittymää jäädyttävän puolen. Kertakutsuna käytettävän FPDF_RenderPageBitmap-funktion rinnalla se tarjoaa progressiivisen vaihtoehdon, joka jakaa sivun työhön osiin. Kutsut FPDF_RenderPageBitmap_Start-funktiota kerran valmistellaksesi renderöinnin kohdebittikarttaa vasten, ja kutsut sitten FPDF_RenderPage_Continue-funktiota toistuvasti. Jokainen Continue rasteroi rajatun siivun ja palauttaa tilan. FPDF_RENDER_TOBECONTINUED tarkoittaa, että työtä on jäljellä, FPDF_RENDER_DONE tarkoittaa että sivu on valmis, ja FPDF_RENDER_FAILED tarkoittaa että toiminto pysähtyi virheeseen. Kun silmukka päättyy, kutsut FPDF_RenderPage_Close-funktiota vapauttaaksesi sivukohtaisen progressiivisen tilan. Koska hallinta palaa koodillesi siivujen välillä, voit käsitellä viestejä, päivittää etenemisosoitinta tai tarkistaa halutaanko työtä edelleen tehdä

PDFiumin tarjoama mekanismi päättämään milloin pitäisi luovuttaa vuoro takaisin kutsujalle on takaisinsoittorakenne nimeltä IFSDK_PAUSE. Se annetaan sekä Start-funktiolle että jokaiselle Continue-kutsulle. Jokaisen työosuuden jälkeen PDFium kutsuu sen NeedToPauseNow-funktio-osoitinta, ja jos se palauttaa nollasta poikkeavan arvon, nykyinen Continue pysähtyy aikaisin ja palauttaa hallinnan tilalla FPDF_RENDER_TOBECONTINUED. Rakenteessa on myös version-kenttä, jonka arvon on oltava 1, sekä vapaamuotoinen user-osoitin, johon PDFium ei koske ja jonka se vain kuljettaa mukanaan muuttamattomana. Juuri tuo koskemattomana pysyvä osoitin on koko seuraavan suunnittelun sarana

Pause-signaalin käyttäminen cancel-signaalina

NeedToPauseNow-kutsun alkuperäinen tarkoitus on aikaviipalointi. Palauta nollasta poikkeava arvo, kun ruutuaikabudjetti on kulutettu, palauta nolla jos renderöinti saa jatkua, ja PDFium keskeyttää työn jotta voit tehdä jotain muuta ennen saman renderöinnin jatkamista. PDFium Component käyttää samaa signaalia uudelleen eri merkityksessä. Sen sijaan että takaisinsoitto vastaisi kysymykseen "pitäisikö minun pysähtyä jotta voit jatkaa myöhemmin", se vastaa kysymykseen "onko tämä työ peruutettu". Nämä kaksi menevät luontevasti päällekkäin siksi, mitä silmukka tekee nähdessään lipun. Aito pause odottaa myöhempää Continue-kutsua; cancel ei odota. Kun kutsuva silmukka havaitsee tokenin peruuntuneen, se sulkee renderöintikontekstin eikä kutsu enää koskaan Continue-funktiota, joten sama nollasta poikkeava paluuarvo, jonka PDFium tulkitsee merkityksessä "pysäytä tämä siivu", muuttuu käytännössä merkitykseksi "pysäytä lopullisesti"

Peruutus ilmaistaan rajapinnalla IPdfCancellationToken, jonka IsCancelled-ominaisuus vaihtuu arvosta false arvoon true, kun jokin toinen ohjelman osa pyytää renderöinnin pysäyttämistä. Silta Pascal-rajapinnan ja PDFiumin C-tyylisen takaisinsoiton välillä on yksi osoitin. Tokenin rajapintaviite kirjoitetaan kenttään IFSDK_PAUSE.user, ja staattinen cdecl-takaisinsoitto lukee sen sieltä takaisin ja kysyy sen tilan. Tämä on klassinen ongelma, kun C-kirjaston annetaan kutsua takaisin Pascal-koodia: takaisinsoiton on oltava tavallinen C-kutsukäytännön funktio eikä olion metodi, koska PDFium tallentaa ja kutsuu paljasta funktio-osoitinta, joka ei tiedä mitään Pascal-olioista eikä Self-viitteestä

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;

Takaisinsoitto palauttaa tokenin tyypittämästä pThis^.user-osoittimen takaisin rajapintatyypiksi ja lukemalla IsCancelled-ominaisuuden. Se ei varaa muistia, lukitse eikä blokkaa, mikä on tärkeää, koska PDFium kutsuu sitä renderöintisäikeessä jokaisen siivun jälkeen ja kaikki tässä tehty työ lisätään itse renderöinnin kustannukseen. Suojaus nil-rakenteen tai nil-user-kentän varalta tarkoittaa, että sama funktio on turvallinen asentaa myös renderöintiin, jolle ei koskaan annettu oikeaa tokenia

Tokenin pitäminen hengissä silmukan ajan

Rajapintaosoittimen muuntaminen paljaan Pointer-osoittimen kautta ja takaisin on juuri se kohta, jossa elinkaarihin liittyvät bugit syntyvät. Delphissä IInterface on viitelaskettu, ja laskuri muuttuu vain kun kääntäjä näkee rajapintatyyppisen muuttujan saavan uuden arvon. Jos token tallennettaisiin vain paljaana osoittimena kenttään IFSDK_PAUSE.user, se piilotettaisiin kokonaan viitelaskurilta. Jos ainoa muu viite tokeniin menisi näkyvistä sillä aikaa kun Continue-silmukka vielä pyörii, olio vapautettaisiin takaisinsoiton alta ja seuraava siivu dereferoisi roikkuvaa osoitinta

Siksi kuvaaja on tietue, jossa on kaksi asiaa eikä yksi. Pause-kenttä on rakenne jonka PDFium lukee. Token-kenttä on oikea rajapintatyyppinen viite, jonka kääntäjä laskee, ja se on olemassa vain siksi että token pysyy muistissa niin kauan kuin tietue elää. Tietue on renderöintirutiinin pinossa oleva paikallinen muuttuja, joten se pysyy voimassa koko silmukan ajan ja puretaan vasta kun rutiini poistuu. Paljas osoitin kentässä user ja laskettu viite kentässä Token nimeävät saman olion; toinen on sitä mitä PDFium voi lukea, toinen estää oliota keräytymästä pois

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

Renderöintikontekstin sulkeminen riippumatta siitä miten silmukka päättyy

Jokainen FPDF_RenderPageBitmap_Start-kutsu varaa progressiivisen tilan, jonka PDFium liittää sivuun, ja tuo tila vapautuu vain kutsumalla FPDF_RenderPage_Close. Ajosilmukasta on kolme ulospääsyä. Sivu valmistuu ja viimeinen tila on FPDF_RENDER_DONE. Token laukeaa ja silmukka poistuu aikaisin raportoidakseen peruutuksen. Jokin epäonnistuu ja tila on FPDF_RENDER_FAILED. Kaikkien kolmen täytyy kutsua Close, ja peruutuspolku on helpoin tehdä väärin, koska luonnollinen tapa "näe peruutus, poistu silmukasta" ohittaa helposti siivouksen matkalla ulos. Jos Close jää saavuttamatta, sivukohtainen tila vuotaa, ja katselin joka antaa käyttäjän peruuttaa renderöintejä jatkuvasti kerää vuodon jokaisesta keskeytetystä sivusta

Kestävä rakenne sijoittaa silmukan ja tuloksen luokittelun try-lohkoon ja FPDF_RenderPage_Close-kutsun vastaavaan finally-lohkoon. Kohdebittikartta tuhotaaan samassa lohkossa. Peruutus voi poistua silmukasta aikaisella Exit-käskyllä ja finally suoritetaan silti, joten progressiivisen tilan vapauttamiseen on tasan yksi paikka eikä sitä voi kiertää

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;

Silmukka tarkistaa tokenin ennen jokaista Continue-kutsua eikä vain luota sen sisällä olevaan takaisinsoittoon. Takaisinsoitto lyhentää nykyisen siivun; silmukan tarkistus estää seuraavan siivun käynnistymisen. Yhdessä ne rajaavat peruutuksen vaikutusajan suunnilleen yhden siivun pituuteen

Kolme lopputulosta ja mitä bittikartassa on peruutuksen jälkeen

Julkinen sisäänkäynti on TPdf.RenderPageProgressive, ja se palauttaa TPdfProgressiveStatus-arvon, joka on joko prsDone, prsCancelled tai prsFailed. Arvot vastaavat Pascal-tyylisesti PDFiumin FPDF_RENDER_*-vakioita mutta nostavat peruutuksen ensimmäisen luokan tulokseksi virheen sijaan

Kohta joka hämmentää ihmisiä on se, mitä kohdebittikartta sisältää tilan prsCancelled jälkeen. Se ei ole tyhjä. PDFium renderöi progressiivisesti samaan bittikarttaan siivu siivulta, joten kun peruutus pysäyttää silmukan, bittikartassa on kaikki se mikä siihen mennessä ehdittiin piirtää, eli osittainen kuva: osa kaistoista on valmiina ja loput näyttävät yhä täyttövärin. Onko osittaisesta tuloksesta hyötyä, riippuu kutsujasta. Katselin joka on juuri hylkäämässä bittikartan käyttäjän siirtyessä muualle voi yksinkertaisesti sivuuttaa sen. Katselin joka haluaa näyttää halvan esikatselun voi pitää sen. Sitä ei saa olettaa, että prsCancelled tarkoittaisi tyhjää tai määrittelemätöntä bittikarttaa; se tarkoittaa totuudenmukaista välähdystä keskeneräisestä renderöinnistä

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;

Nil-token ja haaraton takaisinsoittopolku

Peruutus on valinnainen ominaisuus. Kutsujan, joka haluaa vain progressiivisen renderöinnin viestinkäsittelyn vuoksi ilman aikomusta keskeyttää työtä, pitäisi voida antaa tokeniksi nil. Naiivi tapa tukea tätä on ripotella tarkistuksia "jos token annettiin" sekä takaisinsoittoon että silmukkaan, mikä tarkoittaa haarautumista jokaisella siivulla ja takaisinsoittoa, jonka täytyy käsitellä sekä oikea token että sen puuttuminen

Toteutus välttää tämän korvaamalla puuttuvan tokenin singletonilla. nil-token vaihdetaan arvoon PdfNoCancellationToken, eli rajapintaan jonka IsCancelled on aina false. Sen jälkeen sekä takaisinsoitolla että silmukalla on jokaisessa tapauksessa token jota kysyä, joten kumpikaan ei tarvitse nil-tarkistusta eikä erikoispolkua. Tämä koskaan peruuttamaton token vastaa aina false, takaisinsoitto palauttaa aina nollan ja renderöinti jatkuu loppuun täsmälleen kuten renderöinti, jota ei voi peruuttaa. Valinnainen käyttäytyminen mallinnetaan tokenilla joka ei koskaan laukea, ei tokenin puuttumisella, mikä pitää kuuman polun yhtenäisenä

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

Esille nouseva rakenne on pieni mutta toistamisen arvoinen, koska juuri se on uudelleenkäytettävä osa. C-kirjasto, joka tukee takaisinsoittoa, antaa täsmälleen yhden kanavan välittää tilaa takaisinsoittoon: läpinäkymättömän user-osoittimen. Pane tuon osoittimen taakse laskettu Pascal-rajapintaviite, pidä toinen oikea viite elossa rakenteen vieressä jotta olio ei voi kadota kesken kutsun, ja lue rajapinta takaisin ulos staattisen cdecl-funktion sisällä. Kääri koko ajosilmukka try-lohkoon ja vapauta natiivikonteksti finally-lohossa. Sama mallipohja toimii kaikissa progressiivisissa tai takaisinsoittoihin perustuvissa PDFium-operaatioissa, joissa Pascal-koodin täytyy hallita elinkaarta samalla kun C pitää osoitinta käsissään

Peruutus on vain puolet responsiivisesta katselijasta. Toinen puoli on se, ettei jo kerran piirrettyjä sivuja renderöidä uudelleen, vaan zoomaus ja vieritys pidetään sulavina tarjoamalla välimuistiin tallennettuja bittikarttoja, mistä kerrotaan renderöintivälimuistia ja zoomauksen suorituskykyä käsittelevässä artikkelissamme. Jos haluat nähdä, miten tässä kuvattu peruutettava renderöinti sopii kokonaiseen katselijaan navigoinnin, valinnan ja haun rinnalle, katso artikkeli monipuolisen PDF-katselijan rakentamisesta PDFium Componentilla Delphissä. Tässä kuvattu progressiivinen renderöinti toimitetaan osana PDFium Component -tuotetta Delphiä ja Lazarusta varten yhdessä lataus-, renderöinti- ja lomake-APIen kanssa, joita käsitellään muualla tässä blogissa