Tekninen artikkeli

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

Useimmat PDF-sivut rasteroituvat muutamassa millisekunnissa, etkä koskaan ajattele asiaa. Sitten käyttäjä avaa A1-kokoisen teknisen piirustuksen, kymmenillä tuhansilla vektorivedoilla pakatun sivun tai julisteen, joka on täynnä läpinäkyvyysryhmiä ja pehmeitä maskeja (soft masks), ja se yksi kutsu, joka piirtää sen, vie kaksi tai kolme sekuntia. Jos tuo kutsu ajetaan käyttöliittymäsäikeessä, ikkuna lakkaa piirtämästä itseään uudelleen, otsikkopalkki harmaantuu, ja käyttöjärjestelmä tarjoaa sovelluksen tappamista. Työ on oikeutettua. Sivu todella tarvitsee niin kauan. Vika on siinä, että renderöinti on yksi jakamaton estävä kutsu (blocking call), jolla ei ole mitään tapaa haukata happea eikä mitään tapaa pysähtyä

Tämä artikkeli käsittelee täsmälleen toista näistä kahdesta ongelmasta: pitkän yksisivuisen renderöinnin peruuttamista ilman käyttöliittymän jäätymistä. Käyttäjä napsautti seuraavaa sivua, tai zoomasi, tai sulki asiakirjan, ja käynnissä oleva renderöinti on nyt hukkaan heitettyä työtä, jonka tulisi päättyä heti seuraavassa mahdollisessa tilanteessa sen sijaan, että se ajettaisiin loppuun asti. Vierityksen ja zoomauksen pehmentäminen välimuistittamalla se, mikä on jo rasteroitu, on erillinen huolenaihe omalla suunnittelullaan, jota käsitellään lopussa linkitetyssä sisarartikkelissa. Tässä ainoa kysymys on, kuinka saada yksi progressiivinen renderöinti vastaamaan peruutuspyyntöön nopeasti ja siististi

Progressiivisen renderöinnin ohjelmointirajapinta, joka PDFiumin mukana jo toimitetaan

PDFium ennakoi ongelman jäätymispuoliskon. Kertalaakista tapahtuvan FPDF_RenderPageBitmap-kutsun rinnalla se tarjoaa progressiivisen muunnelman, joka jakaa sivun työpalasiin (chunks). Kutsut FPDF_RenderPageBitmap_Start-funktiota kerran määrittääksesi renderöinnin kohdebittikarttaa vasten, ja sen jälkeen kutsut FPDF_RenderPage_Continue-funktiota toistuvasti. Jokainen Continue rasteroi rajatun siivun ja palauttaa tilan. FPDF_RENDER_TOBECONTINUED tarkoittaa, että tekemistä on vielä, FPDF_RENDER_DONE tarkoittaa, että sivu on valmis, ja FPDF_RENDER_FAILED tarkoittaa, että se pysähtyi virheeseen. Kun silmukka päättyy, kutsut FPDF_RenderPage_Close-funktiota vapauttaaksesi sivukohtaisen progressiivisen tilan. Koska hallinta palautuu koodillesi siivujen välissä, voit pumpata viestejä, päivittää edistymisilmaisinta tai tarkistaa, halutaanko työtä edelleen

Mekanismi, jonka PDFium tarjoaa sen päättämiseen, milloin luovuttaa vuoro (yield), on IFSDK_PAUSE-niminen takaisinkutsutietue (callback struct). Ojennet sen Start-kutsulle ja jokaiselle Continue-kutsulle. Jokaisen palasen jälkeen PDFium kutsuu sen NeedToPauseNow-funktio-osoitinta, ja jos se palauttaa nollasta poikkeavan arvon, nykyinen Continue pysähtyy aikaisin ja antaa hallinnan takaisin tilalla FPDF_RENDER_TOBECONTINUED. Tietue kantaa myös version-kenttää, joka on asetettava arvoon 1, sekä vapaamuotoista user-osoitinta, johon PDFium ei koskaan koske ja jonka se päästää läpi koskemattomana. Tämä koskematon osoitin on koko seuraavan suunnittelun sarana

Tauon uudelleenkäyttö peruutuksena

NeedToPauseNow-kutsun alkuperäinen tarkoitus on aikasiivutus (time-slicing). Palauta nollasta poikkeava arvo, kun ruutubudjettisi on käytetty, palauta nolla jatkaaksesi renderöintiä, jolloin PDFium pitää tauon, jotta voit tehdä jotain muuta ennen saman renderöinnin jatkamista. PDFium-komponentti käyttää uudelleen tätä samaa signaalia eri verbille. Sen sijaan, että se vastaisi "pitäisikö minun pitää tauko ja antaa sinun jatkaa", takaisinkutsu vastaa "onko tämä työ peruutettu". Nämä kaksi mapittuvat toisiinsa siististi sen ansiosta, mitä silmukka tekee, kun se näkee lipun. Aito tauko odottaa myöhempää Continue-kutsua; peruutus ei. Kun kutsuva silmukka havaitsee, että tunniste on peruutettu, se sulkee renderöintikontekstin eikä koskaan enää kutsu Continue-funktiota, joten se sama nollasta poikkeava paluuarvo, jonka PDFium lukee merkityksessä "pysäytä tämä palanen", muuttuu käytännössä merkitykseksi "pysähdy lopullisesti"

Peruutus ilmaistaan rajapinnan, IPdfCancellationToken, kautta, jonka ominaisuus IsCancelled kääntyy falsesta trueksi, kun jokin toinen ohjelman osa pyytää renderöintiä pysähtymään. Silta tuon Pascal-rajapinnan ja PDFiumin C-takaisinkutsun välillä on yksittäinen osoitin. Tunnisteen rajapintaviittaus kirjoitetaan kohteeseen IFSDK_PAUSE.user, ja staattinen cdecl-takaisinkutsu lukee sen takaisin ulos ja tekee siihen kyselyn. Tämä on klassinen ongelma annettaessa C-kirjaston tehdä takaisinkutsu Pascaliin: takaisinkutsun on oltava tavallinen funktio C-kutsukäytännöllä, ei metodi, koska PDFium tallentaa ja kutsuu paljasta funktio-osoitinta, joka ei tiedä mitään Pascal-objekteista tai Self-osoittimesta

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;

Takaisinkutsu palauttaa tunnisteen (token) muuntamalla (cast) arvon pThis^.user takaisin rajapintatyypiksi ja lukee ominaisuuden IsCancelled. Mikään siinä ei varaa muistia, lukitse tai estä (block), millä on merkitystä, koska PDFium kutsuu sitä renderöintisäikeessä jokaisen palasen jälkeen, ja kaikki täällä tehty työ lisätään itse renderöinnin hintaan. Suojus (guard) nilliä tietuetta tai nilliä user-kenttää vastaan tarkoittaa, että sama funktio on turvallinen asentaa jopa renderöintiin, jolle ei koskaan annettu aitoa tunnistetta

Tunnisteen pitäminen elossa silmukan yli

Rajapintaosoittimen muuntaminen (casting) raa'an Pointer-tyypin kautta ja takaisin on se paikka, missä elinkaaribugit syntyvät. IInterface on Delphissä viitelaskettu (reference counted), ja laskuri liikkuu vain, kun kääntäjä näkee rajapintatyyppisen muuttujan saavan arvon. Tunnisteen tallentaminen pelkästään paljaana osoittimena kohteeseen IFSDK_PAUSE.user piilottaisi sen viitelaskurilta kokonaan. Jos ainoa muu viittaus tuohon tunnisteeseen poistuisi näkyvyysalueelta (goes out of scope) sillä aikaa, kun Continue-silmukka on yhä käynnissä, objekti vapautettaisiin takaisinkutsun alta, ja seuraava palanen dereferoisi roikkuvan osoittimen (dangling pointer)

Tästä syystä kuvaaja (descriptor) on tietue, joka sisältää kaksi asiaa, ei yhtä. Pause-kenttä on tietue (struct), jonka PDFium lukee. Token-kenttä on aito rajapintatyyppinen viittaus, jonka kääntäjä laskee, ja se on olemassa ainoastaan kiinnittääkseen (pin) tunnisteen muistiin niin kauan kuin tietue elää. Tietue on paikallinen muuttuja renderöintirutiinin pinossa (stack), joten se pysyy validina silmukan koko keston ajan ja puretaan vasta, kun rutiini poistuu. Paljas osoitin kentässä user ja laskettu viittaus kentässä Token nimeävät saman objektin; toinen on se, minkä PDFium pystyy lukemaan, toinen on se, mikä estää kyseistä objektia joutumasta roskienkeruuseen (collected)

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ä, kuinka silmukka päättyy

Jokainen kutsu FPDF_RenderPageBitmap_Start-funktiolle varaa progressiivisen tilan, jonka PDFium yhdistää sivuun, ja tuo tila vapautetaan vain kutsulla FPDF_RenderPage_Close. Ohjaussilmukasta on kolme poistumistietä. Sivu valmistuu ja viimeinen tila on FPDF_RENDER_DONE. Tunniste laukeaa ja silmukka poistuu aikaisin raportoiden peruutuksen. Jokin epäonnistuu ja tila on FPDF_RENDER_FAILED. Kaikkien kolmen on kutsuttava Close-funktiota, ja peruutuspolku on helpoin saada väärin, koska luonnollinen muoto "näe peruutus, murtaudu ulos" pyrkii ohittamaan siivouksen matkallaan uloskäynnille. Jos Close jää saavuttamatta, sivukohtainen tila vuotaa, ja katseluohjelma, joka antaa käyttäjän peruuttaa renderöinnin toisensa jälkeen, keräisi tuota vuotoa jokaisella keskeytetyllä sivulla

Vankka rakenne sijoittaa silmukan ja tulosten luokittelun try-lohkoon ja FPDF_RenderPage_Close-kutsun sitä vastaavaan finally-lohkoon. Kohdebittikartta tuhotaan samassa lohkossa. Peruutus voi poistua silmukasta varhaisen Exit-kutsun kautta ja finally suoritetaan silti, joten on tasan yksi paikka, joka vapauttaa progressiivisen tilan, eikä sitä voida ohittaa

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 tunnisteen ennen jokaista Continue-kutsua sen lisäksi, että se luottaa sen sisällä olevaan takaisinkutsuun. Takaisinkutsu lyhentää nykyistä palasta; silmukkatarkistus estää seuraavan alkamisen. Yhdessä ne rajaavat peruutuksen voimaantuloajan suurin piirtein yhden palasen kestoon

Kolme lopputulosta, ja mitä bittikartta sisältää peruutuksen jälkeen

Julkinen aloituspiste on TPdf.RenderPageProgressive, ja se palauttaa tyypin TPdfProgressiveStatus, joka on joko prsDone, prsCancelled tai prsFailed. Arvot peilaavat PDFiumin FPDF_RENDER_*-vakioita Pascal-idiomiin mukautettuina, mutta sisällyttävät peruutustapauksen ensimmäisen luokan tuloksena pikemminkin kuin virheenä

Seikka, joka yllättää ihmiset, on se, mitä kohdebittikartta sisältää prsCancelled-tilan jälkeen. Se ei ole tyhjä. PDFium renderöi progressiivisesti samaan bittikarttaan palanen palaselta, joten kun peruutus pysäyttää silmukan, bittikartta sisältää kaiken, mitä siihen hetkeen mennessä oli maalattu, mikä on osittainen kuva: joitakin raitoja valmiina, ja loput näyttävät yhä täyttöväriä. Onko tuo osittainen tulos hyödyllinen, riippuu kutsujasta. Katseluohjelma, joka on aikeissa heittää bittikartan pois, koska käyttäjä siirtyi muualle, voi yksinkertaisesti jättää sen huomiotta. Katseluohjelma, joka haluaa näyttää edullisen esikatselun, voi pitää sen. Mitä et saa tehdä, on olettaa, että prsCancelled viittaa tyhjään tai määrittelemättömään bittikarttaan; se viittaa totuudenmukaiseen tilannekuvaan 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-tunniste ja haarautumaton takaisinkutsupolku

Peruutus on vapaaehtoinen (opt-in). Kutsujan, joka haluaa vain progressiivisen renderöinnin viestien pumppauksen hyödyn vuoksi ilman aikomustakaan keskeyttää, tulisi pystyä antamaan tunnisteeksi nil. Naiivi tapa tukea tätä on ripotella "jos tunniste toimitettiin" -tarkistuksia takaisinkutsuun ja silmukkaan. Tämä tarkoittaa haarautumista (branch) jokaisella palasella ja takaisinkutsua, jonka on käsiteltävä sekä aito tunniste että sen puuttuminen

Toteutus välttää tämän korvaamalla sen singletonilla, kun kutsuja ei anna mitään. nil-tunniste vaihdetaan PdfNoCancellationToken-rajapintaan, jonka IsCancelled on aina false. Tästä pisteestä eteenpäin takaisinkutsulla ja silmukalla on tunniste, jolta kysyä jokaisessa tapauksessa, joten kumpikaan ei tarvitse nil-tarkistusta eikä kumpikaan tarvitse erityistä polkua. Koskaan-ei-peruuteta -tunniste yksinkertaisesti vastaa aina false, takaisinkutsu palauttaa aina nollan, ja renderöinti ajetaan loppuun täsmälleen niin kuin peruuttamatonkin ajettaisiin. Valinnainen käyttäytyminen on mallinnettu tunnisteena, joka ei koskaan laukea, pikemminkin kuin tunnisteen puuttumisena, mikä pitää kuuman polun (hot path) 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;

Muodostuva rakenne on pieni ja arvoinen toistaa, koska se on se uudelleenkäytettävä osa. C-kirjasto, joka tukee takaisinkutsua, antaa sinulle tasan yhden kanavan tilan välittämiseen kyseiseen takaisinkutsuun: läpinäkymättömän käyttäjäosoittimen (opaque user pointer). Laita laskettu Pascal-rajapintaviittaus tuon osoittimen taakse, pidä toinen aito viittaus elossa tietueen (struct) vieressä, jotta objektia ei voida kerätä (collect) kesken kutsun, ja lue rajapinta takaisin ulos staattisen cdecl-funktion sisällä. Kääri koko ohjaussilmukka try-lohkoon ja vapauta natiivi konteksti finally-lohkossa. Sama malli siirtyy mihin tahansa progressiiviseen tai takaisinkutsuohjattuun PDFium-operaatioon, jossa Pascal-koodin on pysyttävä hallinnassa elinkaaresta silloin kun C pitää osoitinta hallussaan

Peruutus on vain yksi puolisko responsiivisesta katseluohjelmasta. Toinen puoli on se, ettei jo piirrettyjä sivuja renderöidä uudelleen, ja että zoomaus ja vieritys pidetään pehmeinä tarjoamalla välimuistitettuja bittikarttoja, mikä on käsitelty artikkelissa renderöintivälimuisti ja zoomaussuorituskyky. Katso kuinka peruutettava renderöinti sopii täydelliseen katseluohjelmaan navigoinnin, valinnan ja haun rinnalla artikkelista monipuolisen PDF-katseluohjelman rakentaminen PDFium VCL -komponentin avulla. Tässä kuvattu progressiivinen renderöinti toimitetaan osana PDFium-komponenttia Delphille ja Lazarukselle yhdessä lataus-, renderöinti- ja lomakeohjelmointirajapintojen kanssa, joita käsitellään muualla tässä blogissa