Tehnički članak

PDF pregledač sa neprekidnim skrolovanjem (continuous scroll) u Delphi-ju pomoću PDFium komponente

Jedna A4 stranica renderovana na ugodnom zumu za čitanje zauzima nekoliko megabajta 32-bitne bitmap slike. Pomnožite to sa ugovorom od 400 stranica i računica prestaje da bude apstraktna: renderujte svaku stranicu unapred i tražićete od Windows-a znatno više od gigabajta bitmapa koje će korisnik gledati ekran po ekran. Aplikacija ili ostaje bez adresnog prostora na 32-bitnoj verziji ili provodi prvih nekoliko sekundi zamrznuta dok grafički procesor (GPU) i parser stranica prolaze kroz stranice do kojih korisnik još nije ni skrolovao. Čitač sa neprekidnim skrolovanjem (continuous scroll) mora da deluje kao jedna dugačka traka stranica, ali ne može zapravo da drži sve njih u memoriji odjednom

Ta tenzija je čitav problem ovde. PDFium komponenta ga rešava unutar klase TPdfView, tako da je većina posla izbor ispravnog režima prikaza (display mode) i razumevanje onoga što komponenta radi u vaše ime. Delovi koje ona ne radi za vas — određivanje veličine stranica za tok čitanja i održavanje odziva pri brzom skrolovanju — jesu mesta gde malo koda opravdava svoj udeo. Ako i dalje sklapate prateće elemente korisničkog interfejsa (traku sa alatkama, sličice, polje za pretragu), vodič kroz pregledač bogat funkcijama pokriva tu temu. Ovde je predmet sam proces skrolovanja

Izgled (layout) je režim prikaza, a ne panel sa bitmapama

Instinkt iz rada sa VCL formama jeste da posegnete za scroll box-om i u njega naslažete kontrole slika, po jednu za svaku stranicu. Oduprite se tome. Taj dizajn vas primorava da sami vodite računa o pozicioniranju stranica, matematici skrolovanja i pitanju memorije odjednom, i sve to ćete ponovo izmisliti na loš način. TPdfView već modelira dokument kao neprekidan niz stranica i izlaže taj raspored kroz svoje svojstvo DisplayMode

Pdf := TPdf.Create(Self);
PdfView := TPdfView.Create(Self);
PdfView.Parent := Self;
PdfView.Align := alClient;
PdfView.Pdf := Pdf;

PdfView.DisplayMode := dmSingleContinuous;   // one page wide, scrolls vertically

Pdf.FileName := 'contract.pdf';
Pdf.Active := True;
if not Pdf.Active then
  ShowMessage('Could not open the document');

To je čitavo podešavanje za neprekidno skrolovanje. Režim dmSingleContinuous raspoređuje stranice u jednu vertikalnu kolonu sa razmacima između njih koji se rešavaju interno, i pogled skroluje kroz tu kolonu kao kroz jednu površinu. Nema kontrola na nivou stranica koje treba povezivati i nema hendlera skrolovanja koje treba pisati za uobičajenu navigaciju. Obratite pažnju na proveru svojstva Pdf.Active nakon dodele: otvaranje dokumenta nikada ne podiže izuzetke, tako da oštećena ili lozinkom zaštićena datoteka ostavlja Active na vrednosti False bez ikakvog izuzetka koji bi se mogao uhvatiti, a pregledač koji preskoči ovu proveru prikazuje prazan panel i krivi sebe za to

Isto svojstvo nosi režime rasporeda na dve stranice (spread modes). dmTwoPageContinuous postavlja stranice jednu pored druge, po dve u redu, za čitanje u stilu knjige koje neki dokumenti zahtevaju. dmTwoPageContinuousWithCover radi isto to, ali dopušta da prva stranica stoji samostalno kao korica, tako da preostali rasporedi padaju na prirodnu granicu parnih i neparnih stranica. Sva tri režima skroluju neprekidno. Prelazak između njih je jedna obična dodela vrednosti, što dodavanje padajuće liste za izbor režima prikaza kasnije čini trivijalnim

Samo vidljive stranice se rasterizuju

Razlog zašto ovo radi na datoteci od 400 stranica jeste to što je kolona virtuelna. Klasa TPdfView zna visinu svake stranice iz stabla stranica dokumenta, tako da može izračunati ukupan opseg skrolovanja i poziciju svake stranice bez rasterizacije bilo čega. Rasterizacija, skup korak koji pretvara tok sadržaja stranice u piksele, dešava se samo za stranice koje trenutno presecaju prozor za prikaz (viewport), uz malu marginu kako bi stranica bila spremna do trenutka kada se skroluje u vidno polje. Kako skrolujete nadole, stranice koje ulaze u prozor za prikaz se renderuju, a onima koje ga napuštaju oslobađaju se bitmap slike. Potrošnja memorije ostaje proporcionalna onome što staje na ekran, a ne dužini dokumenta

Ovo vredi usvojiti jer menja način na koji razmišljate o računarskim troškovima. Otvaranje dokumenta od 400 stranica je jeftino: parsira se struktura, a ne sadržaj. Trošak se plaća po stranici i to lenjo (lazily) — u trenutku kada se stranica skroluje u blizinu. Pregledač koji deluje trenutno pri otvaranju i glatko pri skrolovanju ne radi manje posla ukupno, već ga raspoređuje duž stvarne korisničke putanje čitanja i odbacuje ono što ostane iza nje. Praktična posledica je da skoro nikada ne želite da forsirate renderovanje stranica ispred korisnika. Pustite da pogled (view) odluči šta je vidljivo

Prilagodite veličinu stranica širini, pa ostavite zum na miru

Kolona za čitanje zahteva da veličina stranica bude prilagođena širini panela, a ne fiksirana na apsolutni zum. Svojstvo FitMode radi upravo to i nastavlja sa tim kako se prozor menja

PdfView.FitMode := pfmFitWidth;   // each page fills the column width; height follows

Sa postavkom pfmFitWidth komponenta ponovo izračunava zum kad god se veličina pogleda promeni, tako da kolona uvek popunjava dostupnu širinu, a visine stranica i opseg skrolovanja slede iz toga. Postoji jedna zamka u koju ljudi upadaju: direktno dodeljivanje vrednosti svojstvu Zoom vraća FitMode nazad na pfmNone. To je namerno, jer su ručni zum i automatsko uklapanje kontradiktorne namere, ali to znači da zalutali poziv PdfView.Zoom := 1.0 negde u vašem kodu prećutno isključuje uklapanje po širini i sledeća promena veličine prestaje da reflektuje izmene izgleda. Ako nudite i kontrolu zuma i dugme za uklapanje, tretirajte ih kao promenu režima rada: postavljanje jednog briše drugo, a vi odlučujete šta pobeđuje

Za kontrole apsolutnog zuma koje se čitaju prirodno, pogled izlaže zumove za uklapanje kao vrednosti koje možete primeniti ili prikazati: PageWidthZoom[PageNumber] vraća zum koji bi uklopio tu stranicu po širini, a odgovarajući PageZoom uklapa čitavu stranicu. Čitanje ovih vrednosti je način na koji popunjavate meni "Uklopi po širini" / "Uklopi stranicu" bez ručnog kodiranja magičnih procenata koji greše na horizontalnim (landscape) ili prevelikim stranicama

Održite brz odziv pri skrolovanju pomoću progresivnog renderovanja

Podrazumevana putanja renderovanja iscrtava stranicu do kraja pre nego što završi rad. Za jednu stranicu to je u redu. Tokom brzog skrolovanja kroz gust dokument to nije slučaj: svaka stranica koja bljesne pokreće punu rasterizaciju, i ako korisnik skroluje brže nego što se stranice mogu renderovati, ta renderovanja se gomilaju i panel počinje da se koči jer se radi posao za stranice koje su već van ekrana do trenutka kada se rendering završi. Rešenje je da se renderovanje učini otkazivim i da se napusti onog trenutka kada korisnik pređe dalje

Metoda RenderPageProgressive renderuje u delovima (chunks) i proverava token za otkazivanje na granici svakog dela, tako da se započeto renderovanje stranice koja je upravo skrolovana van ekrana može odbaciti umesto da se izvršava do kraja

type
  TFormMain = class(TForm)
    // ...
  private
    FRenderCancel: IPdfCancellationTokenSource;
    procedure RenderPageToBitmap(PageNo: Integer; Bmp: TBitmap);
  end;

procedure TFormMain.RenderPageToBitmap(PageNo: Integer; Bmp: TBitmap);
var
  Status: TPdfProgressiveStatus;
begin
  // Cancel whatever was rendering; the old token is now signaled.
  if Assigned(FRenderCancel) then
    FRenderCancel.Cancel;
  FRenderCancel := TPdfCancellationTokenSource.New;

  Pdf.PageNumber := PageNo;
  Status := Pdf.RenderPageProgressive(Bmp, 0, 0, Bmp.Width, Bmp.Height,
    FRenderCancel.Token);

  case Status of
    prsDone:      ;                    // bitmap is complete, paint it
    prsCancelled: Exit;                // superseded, discard this result
    prsFailed:    ShowMessage('Render failed for page ' + IntToStr(PageNo));
  end;
end;

Oblik koji je važan jeste povratna vrednost. Vrednost prsDone znači da je bitmapa potpuno nacrtana i spremna za prikaz na ekranu. prsCancelled znači da je novija pozicija skrolovanja zamenila ovu stranicu, pa delimični rezultat odbacujete umesto da ga prikažete. prsFailed je stvarna greška na toj stranici. Otkazivanje se proverava prozivanjem (polling) na granicama delova pre nego preemptivno, tako da očekujte desetine milisekundi kašnjenja (latency) između pozivanja Cancel i stvarnog zaustavljanja renderovanja. To je i dalje daleko jeftinije nego da dozvolite da zastarelo renderovanje čitave stranice blokira red čekanja. Prosleđivanje vrednosti nil kao tokena renderuje direktno do kraja, što je pravi izbor za jednokratno renderovanje kao što je pregled pre štampanja gde nema ničega protiv čega bi se proces otkazao

Kada umesto toga pozovete funkcijski oblik metode RenderPage, onaj koji vraća novu TBitmap sliku, zapamtite da je pozivalac njen vlasnik i da je mora osloboditi pozivom Free. U petlji skrolovanja koja dodeljuje bitmapu po stranici, zaboravljanje ovoga je curenje (leak) koje raste sa svakom stranicom koju korisnik prođe, što je upravo pad sa neograničenom potrošnjom memorije koji je kontinualni dizajn trebalo da izbegne. Renderujte u ponovo korišćenu bitmapu kad god možete

Ono što vam preostaje

Čitač sa neprekidnim skrolovanjem je uglavnom posao same komponente. Vi birate dmSingleContinuous za izgled, podešavate pfmFitWidth kako bi se kolona refaktorisala sa prozorom, i proveravate Pdf.Active kako bi neispravna datoteka jasno prijavila grešku. Jedini deo koji vredi napisati samostalno jeste otkazivo renderovanje, jer se kvalitet čitača procenjuje na osnovu toga kako se ponaša kada neko povuče traku za skrolovanje do dna dugačkog dokumenta i panel to ili prati ili ne. Sve preko toga — selekcija teksta kroz stranice, isticanje rezultata pretrage, stablo obeleživača — jeste posao na korisničkom interfejsu koji sedi na vrhu ove površine skrolovanja, a ne unutar nje

API-ji TPdfView, DisplayMode i RenderPageProgressive prikazani ovde su deo PDFium komponente za Delphi i Lazarus