Tehnički članak

PDF Viewer Disk Cache and Per-Monitor DPI in Delphi

Dve zamerke uvek prate ugrađene PDF pregledače: veliki dokumenti se ponovo renderuju od nule pri svakom pokretanju, a tekst postaje mutan onog trenutka kada se prozor prevuče na monitor sa visokim DPI-jem. losLab PDF biblioteka rešava oba problema u svojoj kontroli TPDFlibViewer: svojstvo DiskCacheFolder omogućava keširanje stranica na disku kroz više sesija, pri čemu je ključ otisak prsta (fingerprint) dokumenta, a svojstvo ScreenDPI, podržano funkcijom GetDpiForWindow i hendlerom poruke WM_DPICHANGED, daje kontroli pravu DPI svest po monitoru (per-monitor DPI awareness)

Oba problema vredi shvatiti ozbiljno jer ih korisnici odmah primećuju. Skenirani ugovor od 400 stranica za čije je skrolovanje juče trebalo osam sekundi da postane dostupno, i danas ponovo zahteva osam sekundi, iako se u datoteci ništa nije promenilo. A na radnoj površini sa mešovitim DPI-jem, pregledač koji renderuje na podrazumevanih 96 DPI primarnog monitora tamo izgleda dobro, a vidljivo mutno na 4K panelu pored njega. Ovaj članak opisuje kako TPDFlibViewer rešava oba problema i, što je još korisnije, zašto je dizajn postavljen na ovaj način. Ako ste novi u korišćenju same kontrole, počnite sa pregledom kontrole interaktivnog PDF pregledača, a zatim se vratite ovde za sloj performansi

Kako keširati renderovane PDF stranice kroz sesije u Delphi-ju?

Dodelite direktorijum svojstvu TPDFlibViewer.DiskCacheFolder i kontrola će sačuvati bitmape renderovanih stranica na disk, tako da se nedavno pregledani dokument otvara trenutno umesto da se ponovo renderuje. Raspored na disku je sledeći: <Folder>\PDFlibPas-PageCache\<document fingerprint>\p<page>_z<zoomkey>.bin: po jedan poddirektorijum za svaki dokument, i po jedna datoteka za svaku kombinaciju stranice i nivoa zumiranja. Sve ostalo je automatski — nema API-ja za keš koji treba pozivati niti ičega što treba ručno prazniti

uses
  PDFlibViewer;

procedure TMainForm.FormCreate(Sender: TObject);
begin
  FViewer := TPDFlibViewer.Create(Self);
  FViewer.Parent := Self;
  FViewer.Align := alClient;
  // One line turns on the cross-session page cache
  FViewer.DiskCacheFolder := 'C:\ProgramData\MyApp\PageCache';
  FViewer.LoadFromFile('C:\Contracts\master-agreement.pdf', '');
end;

Brojač DiskCacheHits prijavljuje koliko je renderovanja stranica keš na disku uštedeo u tekućoj sesiji, što čini ovu dobit merljivom umesto anegdotskom. Na dokumentu koji ste zatvorili i ponovo otvorili, svaka stranica koja se skroluje u prikaz bez prolaska kroz renderovanje uvećava ovaj brojač

Zašto je ključ keša otisak prsta (fingerprint), a ne broj verzije

Otisak prsta (fingerprint) dokumenta je 64-bitni FNV-1a heš nad stringom path|size|last write time, formatiran kao šesnaest heksadecimalnih cifara. Ta trojka predstavlja čitavu strategiju invalidacije keša: kada se datoteka izmeni, njena veličina ili vreme poslednjeg upisa se menjaju, heš se menja, a pregledač jednostavno otvara drugi poddirektorijum keša. Zastareli direktorijum se više nikada ne koristi i na kraju biva obrisan kroz LRU čišćenje. Nema protokola za verzije koji treba održavati, nema logike poređenja vremenskih oznaka koja može da odstupi, niti načina da se stara bitmapa isporuči za izmenjeni dokument

To je ista ideja "identitet sadržaja kao ključ keša" koju koriste sistemi za izgradnju (build systems), i ona opravdava svoj trud kroz eliminaciju potencijalnih grešaka. Keš koji čuva datoteke pod običnim imenom dokumenta mora eksplicitno da detektuje izmene, a svaka eksplicitna provera je bag koji čeka granični slučaj: datoteka vraćena iz rezervne kopije sa starom vremenskom oznakom, čuvanje koje zadržava istu veličinu ili putanja koja se na jednoj mašini poredi sa razlikovanjem velikih i malih slova, a na drugoj ne. Heširanje sva tri signala u naziv direktorijuma čini zastarelost strukturno nemogućom, umesto da se ona proceduralno proverava

Zašto je rotacija namerno izostavljena iz ključa na disku

Ime datoteke keša kodira samo broj stranice i nivo zumiranja — ugao rotacije prikaza je namerno odsutan. Keš na disku uvek čuva nerotirane bajtove renderovanja; kada se keširana stranica učita dok je prikaz rotiran, pikseli se reorijentišu u memoriji nakon dekodiranja. Ako bi rotacija bila deo ključa na disku, korisnik koji prolazi kroz četiri ugla prikaza bi istu stranicu upisao na disk četiri puta, četvorostruko povećavajući rast keša za nula dodatnih informacija, pošto je rotacija jeftino mešanje piksela u poređenju sa punim renderovanjem

Keš bitmapa u memoriji koristi kompozitni ključ, Round(Zoom * 1000) * 4 + Rotation div 90, jer je tamo rotirana bitmapa upravo ono što treba hendleru za iscrtavanje. Pozadinska nit za predučitavanje (prefetch thread) prati istu disciplinu: uvek renderuje nerotirane stranice i prijavljuje obične ključeve zumiranja, a glavna nit primenjuje rotaciju kada preuzima rezultate — isto pravilo "glavna-nit-poseduje-keš" o kojem je pisano u članku o paralelnom renderovanju stranica i bezbednosti niti. Držanje rotacije van svega što je ispod sloja memorije znači jedno kanonsko renderovanje po stranici i zumiranju, svuda gde je skladištenje skupo

Kako LRU čišćenje drži direktorijum keša ograničenim?

Keš zadržava do deset direktorijuma dokumenata i uklanja one najmanje skoro korišćene (LRU) kada stignu novi dokumenti. Skorašnjost se prati preko datoteke last-used.marker u svakom direktorijumu dokumenta, čija se vremenska oznaka ažurira i pri pisanju i pri čitanju iz keša, tako da dokument koji samo ponovo čitate ostaje aktuelan bez ponovnog upisivanja. Direktorijum koji pripada trenutno otvorenom dokumentu je izuzet od brisanja, tako da čišćenje nikada ne može povući stranice ispod aktivnog prikaza

Svaka operacija na disku unutar keša — računanje otiska prsta, kreiranje direktorijuma, ažuriranje markera, čitanje i pisanje stranica, čišćenje — guta sopstvene izuzetke (exceptions). To je nameran stav, a ne nepažnja: keš je akcelerator koji radi po principu najboljeg truda (best-effort), pa pun disk, direktorijum samo za čitanje ili blokada od strane antivirusa moraju samo degradirati rad pregledača na "renderuje kao i ranije", a nikada dovesti do "neuspeha u prikazu stranice". Najgori ishod koji greška u ulazu/izlazu keša može da proizvede jeste propušten pogodak (cache miss)

Kako učiniti PDF pregledač DPI-svesnim u Delphi-ju?

Komponenta TPDFlibViewer usmerava svaku kalkulaciju zavisnu od DPI-ja kroz jedno polje, i taj dizajn sa jednom tačkom promene je ono što čini podršku po monitoru upravljivom. Kod za raspored, predučitavanje i renderovanje čita jednu vrednost FScreenDPI; pri zumiranju od 100%, jedna PDF tačka se mapira u ScreenDPI / 72 piksela. Javni setter svojstva ScreenDPI čisti keš bitmapa, ponovo gradi raspored, ažurira opsege skrolovanja i invalidira prozor, tako da je promena DPI-ja jedna dodela koju svi konzumenti automatski prate. Ista matematika piksela je važna kada renderujete za štampač umesto za monitor, što je tema pokrivena u članku o pregledu štampe i renderovanju konteksta uređaja

Detekcija se vrši u metodi CreateWnd, a ne u konstruktoru, jer je funkciji GetDpiForWindow potreban važeći handle prozora, a konstruktor se izvršava mnogo pre nego što on nastane. Dok se CreateWnd ne aktivira, kontrola drži stariju podrazumevanu vrednost 96 kako bi matematika rasporeda ostala ispravna. Sama funkcija GetDpiForWindow je Windows 10 1607+ API, pa je kontrola dinamički povezuje preko GetProcAddress i vraća se na GetDeviceCaps(LOGPIXELSY) na starijim sistemima — unit se i dalje učitava svuda, a stariji Windows jednostavno prijavljuje sistemski DPI

Šta se dešava kada se prozor pomeri na 4K monitor?

Windows šalje poruku WM_DPICHANGED kada prozor sa svesti o DPI-ju po monitoru pređe na monitor sa različitim faktorom skale, a TPDFlibViewer rukuje njome direktno. Hendler čita novi DPI iz niske reči (low word) parametra wParam, dodeljuje ga preko metode SetScreenDPI — što odbacuje sada pogrešan keš bitmapa i ponovo raspoređuje stranice — a zatim prihvata predloženi pravougaonik prozora koji Windows prosleđuje u lParam preko poziva SetWindowPos, što održava logičku veličinu klijenta stabilnom tokom pomeranja. Korisnik vidi kako se dokument renderuje oštro u novoj skali, umesto da vidi bitmapu koju je rastegao operativni sistem

// Normally you never touch ScreenDPI: CreateWnd detects the host monitor
// and WM_DPICHANGED tracks moves. Override it only for special targets,
// e.g. rendering the layout as if for a 150% display:
FViewer.ScreenDPI := 144;  // clears the bitmap cache and rebuilds layout

Granice koje vredi znati pre isporuke

Tri praktična ograničenja zaslužuju mesto u vašem pregledu dizajna. Prvo, poruka WM_DPICHANGED stiže samo ako se proces prijavi (opts in): manifest vaše aplikacije mora deklarisati svest o DPI-ju po monitoru (per-monitor v2 na modernom Windows-u), što je u Delphi IDE-u opcija DPI awareness pod Application podešavanjima. Proces koji je svestan samo sistemskog DPI-ja nikada ne prima ovu poruku, i kontrola tada renderuje na DPI-ju koji je detektovan pri kreiranju prozora. Drugo, keš na disku menja prostor na disku za brzinu — bitmape stranica za deset dokumenata kroz nekoliko nivoa zumiranja predstavljaju realan skladišni prostor, pa usmerite DiskCacheFolder na lokaciju za koju ste spremni da raste, a ne na roaming profil. Treće, direktorijumu keša je potrebna dozvola za pisanje za tekućeg korisnika; bez nje pregledač i dalje radi tiho, sa nula pogodaka (hits) — proverite DiskCacheHits u testiranju ako sumnjate da se keš ne aktivira

Zajedno, ove dve funkcije više menjaju percipirani kvalitet aplikacije sa velikim brojem dokumenata nego većina optimizacija renderovanja: dokumenti koje vaši korisnici ponovo posećuju otvaraju se trenutno, a prikaz ostaje oštar na kom god monitoru da se nađu. Obe funkcije se isporučuju kao obična svojstva na TPDFlibViewer u losLab PDF biblioteci, tako da je njihovo usvajanje popodne povezivanja koda, a ne projekat razvoja mehanizma za renderovanje