Kada HotPDF Delphi Component učita PDF 1.5 fajl kroz LoadFromFile, on ne parsira objekte spakovane unutar /Type /ObjStm kontejnera. Beleži gde svaki komprimovani član živi i parsira ga samo kada nešto zatraži. Taj lenji invarijant je ono što drži vreme učitavanja proporcionalnim onome što stvarno dirate, i ujedno je razlog zašto puno prepisivanje mora da obavi jedan dodatni posao pre nego što ijedan bajt izađe: razvije svaki član koji je još neparsiran, jer će prepisivanje upravo odbaciti kontejnere u kojima ti članovi žive
Simptom koji je pokrenuo ovu belešku lako je opisati i neprijatno je debugirati. Učitajte fajl čiji fontovi, color space-ovi i stablo strukture sede u object stream-ovima, propustite ga kroz par za generisanje BeginDoc i EndDoc, i izlaz se otvara bez prigovora. Broj stranica je ispravan, tekst je vidljiv na stranicama koje proverite. Zatim kolega otvori stranicu 40 i tekst se iscrtava zamenskim fontom, ili komanda Extract Text vraća smeće tamo gde je nekada bila ActualText zamena. Ništa nije palo. Writer je prosto serijalizovao objekat koji nikada nije učitan, a neučitan objekat se serijalizuje kao ništa
Šta LoadFromFile zapravo čuva za komprimovani objekat?
Za svaki cross-reference unos tipa 2, LoadFromFile čuva mali zapis u FCompactObjects: broj objekta, indeks kontejnera koji ga sadrži u tabeli kontejnera, poziciju člana unutar tog stream-a, i pointer ParsedObject koji počinje kao nil. Sam kontejner se locira, dešifruje ako je dokument šifrovan, i inflatuje, ali tela članova ostaju kao bajtovi. ISO 32000-1 §7.5.7 definiše raspored kontejnera koji to omogućava: header sa parovima broj objekta i offset, pa tela članova spojena posle /First, pa se svaki pojedinačni član može iseći bez dodirivanja svojih suseda
EnsureCompressedObjectLoaded je jedina putanja koja zapis pretvara u objekat. Nalazi zapis po broju objekta, i ako je ParsedObject već postavljen vraća taj keširani objekat i broji cache hit. Inače ponovo učitava kontejner ako je bio izbačen, računa bajt opseg člana iz tabele offseta, predaje parseru view tog isečka bez kopiranja, i smešta rezultat natrag u zapis. Od tog trenutka objekat je indirektan, nosi svoj pravi broj objekta, i registrovan je u indeksu objekata dokumenta kao svaki objekat parsiran iz tela fajla. Katalog, info rečnik, koren stabla stranica i objekti stranica prolaze kroz tu putanju pri učitavanju jer su navigaciji potrebni. Fontovi, color space-ovi, ExtGState rečnici i elementi strukture ne prolaze, i ostaju kao zapisi dok ih render stranice ili prepisivanje ne dotakne
Možete to posmatrati i spolja. GetLoadedObjectStreamCacheInfo prijavljuje koliko kontejnera postoji, koliko je članova indeksirano i koliko je od njih do sada parsirano:
var
Pdf: THotPDF;
Info: THPDFObjectStreamCacheInfo;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.LoadFromFile('tagged-report.pdf');
if Pdf.GetLoadedObjectStreamCacheInfo(Info) then
Writeln(Format('%d containers, %d members indexed, %d parsed so far',
[Info.ContainerCount, Info.IndexedObjectCount,
Info.MaterializedObjectCount]));
finally
Pdf.Free;
end;
end;
Na fajlu sa mnogo strukture treći broj je mali deo drugog odmah posle učitavanja. Taj jaz je cela poenta lenjog učitavanja, i ujedno je tačno onaj skup objekata po koji puno prepisivanje mora da se vrati
Zašto puno prepisivanje izgubi fontove koje inkrementalno čuvanje čuva?
Puno prepisivanje odbacuje /ObjStm i /XRef kontejnere izvornog fajla i ponovo serijalizuje graf objekata od nule, pa svaki član čiji je ParsedObject još nil nema nikakvu reprezentaciju u izlazu. Inkrementalno ažuriranje nikada nema taj problem, jer dodaje nove objekte posle originalnih bajtova i ostavlja stare kontejnere na mestu da ih adresira prethodna cross-reference sekcija. Razlika nije u tome kako dva režima tretiraju fontove. Nego u tome da li originalni kontejneri preživljavaju da bi ih sledeći pregledač pročitao
Popravka živi u SaveToStream, serijalizatoru koji EndDoc pokreće bilo da postavite FileName ili OutputStream. Pre nego što se uputi u bilo koju granu writer-a, prolazi kroz FCompactObjects i poziva EnsureCompressedObjectLoaded nad svakim unosom. Ako se član ne može učitati, čuvanje podiže grešku umesto da nastavi, jer je prepisivanje koje tiho ispusti font rečnik gore od onog koje se zaustavi. Razvijanje mora da stoji na tom nivou, iznad klasične, packed i linearized grane, i iznad pruning-a ponovo učitanih strukturnih stream-ova koji radi linearized putanja. Ranija verzija razvijala je članove samo unutar SaveLoadedDocument, što je pokrivalo rečnik učitanog dokumenta a potpuno promašilo rečnik generisanja. LoadFromFile praćen BeginDoc, izmenama stranica i EndDoc išao je pravo do writer-a sa svakim netaknutim članom još neparsiranim
// Oba rečnika prepisivanja sada razvijaju compact članove pre nego što writer krene.
// Putanja učitanog dokumenta:
Pdf.LoadFromFile('quarterly.pdf');
Pdf.SaveLoadedDocument('quarterly-rewritten.pdf');
// Putanja generisanja nad učitanim fajlom:
Pdf.LoadFromFile('quarterly.pdf');
Pdf.FileName := 'quarterly-stamped.pdf';
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('Arial', [], 9);
Pdf.CurrentPage.TextOut(40, 20, 0, 'Reviewed 2026-09-11');
Pdf.EndDoc; // SaveToStream prvo materijalizuje svaki FCompactObjects unos
Keširani članovi zadržavaju sve što ste im uradili. Objekat koji je parsiran, izmenjen i označen kao prljav pre čuvanja vraća se iz cache-a sa svojim izmenama, a član koji ste izbrisali zadržava svoje stanje brisanja kroz ponovljena čuvanja. Prolaz razvijanja je idempotentan po konstrukciji: uvek popunjava samo nil slotove
Zašto provere piksela na tri stranice promaše ActualText slučaj
Elementi strukture su mesto gde se ovaj bug krije najduže. ActualText unos na sekvenci označenog sadržaja, definisan u ISO 32000-1 §14.9.4, zamenjuje glifove za izvlačenje i pristupačnost ali ne utiče na renderovanje. Ako element strukture živi u object stream-u a prepisivanje ga izgubi, stranica se i dalje iscrtava ispravno, prva, srednja i poslednja stranica porede se piksel po piksel sa izvorom, a regresija se pokaže tek kada neko pokrene izvlačenje teksta ili screen reader. Test prepisivanja koji samo renderuje stranice nije test prepisivanja za označeni PDF. Diff-ujte i izvučeni tekst i stablo strukture
Kako prazna korisnička lozinka menja učitavanje?
Prazna korisnička lozinka i dalje znači da je fajl šifrovan, a object stream-ovi u takvom fajlu su ciphertext dok se ključ fajla ne povrati. ISO 32000-1 §7.6.3.4 algoritam 2 izvodi taj ključ iz lozinke, unosa /O, /P i prvog identifikatora dokumenta, i HotPDF mora da ga pokrene nad praznim string-om pre nego što prolaz tipa 2 može da inflatuje ijedan kontejner. Zato BeginDoc na učitanom šifrovanom dokumentu poziva DecryptLoadedDocument sa praznom lozinkom pre svega ostalog: graf objekata mora biti autentifikovan i dešifrovan pre nego što prepisivanje može da počne, bez obzira na to da li pozivalac namerava da zaštiti izlaz. Šifrovanje izlaza je odvojena odluka, vođena zaštitnim podešavanjima pozivaoca, i BeginDoc vraća ta podešavanja posle prolaza dešifrovanja da se šifrovan ulaz ne pretvori tiho u šifrovan izlaz
Politika kontejnera čita se iz /Encrypt rečnika pre nego što se ijedna lozinka pokuša. Za /V 1 i 2 svaki stream je šifrovan ključem fajla. Za crypt filter-e HotPDF razrešava /StmF kroz /CF: Identity filter ili /CFM vrednosti None znači kontejnere čistog teksta, dok V2 i AESV2 znače šifrovane. Odgovor sleće u FReloadObjectStreamsEncrypted, i važan je za jedan konkretan slučaj. Kada su kontejneri čist tekst a stringovi nisu, članovi nose šifrovane stringove koji se moraju dešifrovati pojedinačno, pa MaterializeMembersOfPlaintextObjectStreams razvija svaki compact član pre prolaza dešifrovanja po objektu. Ne radi ništa kada politika još nije poznata i ništa kada su sami kontejneri bili šifrovani, jer su članovi šifrovanog kontejnera već dešifrovani zajedno sa njim i nikada se ne smeju dešifrovati dvaput
Šta se dešava kada se kontejner ne može dešifrovati?
Kontejner koji padne pri dešifrovanju ide u karantin, nije fatalan. Prolaz tipa 2 beleži unos THPDFObjStmQuarantineInfo u FObjStmQuarantine sa brojem objekta kontejnera, THPDFObjStmQuarantineReason, dijagnostičkim string-om, i listom brojeva članova koje je cross-reference usmerio u njega. osqrDecryptFailed se podiže za četiri različite situacije: nijedan crypt filter nije mogao biti razrešen, AES-256 ili AES-GCM dešifrovanje je bacilo izuzetak, nasleđeno RC4 ili AES-128 dešifrovanje je bacilo izuzetak, ili uopšte ne postoji upotrebljiv ključ fajla. Nezavisni kontejneri nastavljaju da se učitavaju, pa se dokument sa jednim oštećenim kontejnerom i dalje otvara i i dalje iscrtava svaku stranicu koja od njega ne zavisi
Lista karantina preživljava fallback parsera. Ako primarno učitavanje cross-reference tabele padne i HotPDF rekonstruiše tabelu objekata skeniranjem fajla, encrypted flag iz prvog pokušaja možda ne preživi tu rekonstrukciju, ali zapisi karantina preživljavaju. Zato BeginDoc proverava listu karantina a ne encrypted flag: na učitanom dokumentu prolazi kroz FObjStmQuarantine i podiže grešku na prvom unosu osqrDecryptFailed, imenujući kontejner i tražeći ponovno učitavanje sa validnom lozinkom. Prepisivanje koje bi nastavilo posle te tačke upisalo bi članove koje je kontejner trebalo da drži kao prazne objekte i prijavilo uspeh. Isti test možete pokrenuti sami, ranije i sa sopstvenom politikom, kroz javne pristupne metode:
var
Info: THPDFObjStmQuarantineInfo;
I: Integer;
begin
Pdf.LoadFromFile('vendor-form.pdf'); // prazna korisnička lozinka
for I := 0 to Pdf.GetLoadedQuarantinedObjStmCount - 1 do
if Pdf.GetLoadedQuarantinedObjStmInfo(I, Info) and
(Info.Reason = osqrDecryptFailed) then
raise Exception.CreateFmt(
'Object stream %d is unreadable (%s); %d members unresolved',
[Info.ContainerObjNum, String(Info.Diagnostic),
Length(Info.MemberObjNums)]);
// odavde je bezbedno prepisivati
end;
Ostali razlozi karantina pokrivaju ne-kriptografske neuspehe: kontejner koji nije stream, rečnik koji nedostaje, nevalidan /N ili /First, veličina stream-a van prihvaćenog opsega, neuspeh dekompresije, /First koji pokazuje iza podataka, ili telo člana koje se dekodiralo ali nije parsiralo. To vredi logovati pri prijemu, jer svaki od njih imenuje tačno one članove koji će vam nedostajati nizvodno
Zašto prepisivanje zahteva originalni numerički token?
HotPDF čuva svaki numerički objekat kao Single, a Single ne može da reprodukuje izvorni tekst realnog broja. ISO 32000-1 §7.3.3 dopušta da writer emituje 0.750000, .75 ili 0.75 za istu vrednost, i nijedan od njih ne preživljava round trip kroz 24-bitni binarni zapis i generički formater nepromenjen. Gore od toga, vrednost kao 0.7 uopšte nije predstavljiva u Single-u; parsira se u najbliži float, a reformatiranje tog float-a može da proizvede 0.69999999 ili zaokruženog suseda u zavisnosti od petlje po ciframa. Na boji ispune ili /CA konstanti transparentnosti to je razlika od jednog koraka u 8-bitnom kanalu, što je dovoljno da padne poređenje sa izvorom po pikselima i, na granicama gradijenta, dovoljno da se vidi
THPDFNumericObject.RememberSourceToken rešava to za neizmenjeni slučaj. Parser ga poziva sa sirovim tokenom odmah posle dodele Value; metoda prihvata samo tokene sastavljene od cifara, najviše jedne decimalne tačke i opcionog znaka na početku, i smešta token zajedno sa vrednošću kojoj je odgovarao u FSourceValue. Svojstvo SourceToken vraća sačuvani tekst samo dok je Value još jednak FSourceValue. Promenite broj i token ispari, pa izmenjena vrednost uvek ide kroz postojeću putanju formatiranja i nikada ne emituje zastareo tekst. SaveNumericObject prvo proverava SourceToken i upisuje ga verbatim kada postoji, pa se spušta na grane za cele brojeve, reference color space-a i frakcione brojeve samo za brojeve koji su kreirani ili izmenjeni u memoriji
Invarijant je mali i vredi ga izneti jasno: broj koji niste dirali upisuje se bajtovima kojima je pročitan, a broj koji ste dirali upisuje HotPDF-ov sopstveni formater. Compact članovi imaju istu korist kao objekti iz tela fajla, jer EnsureCompressedObjectLoaded pokreće isti parser nad isečkom člana. Samo formatiranje brojeva, i njegova nezavisnost od locale-a procesa, obrađeni su u članku o formatiranju PDF brojeva nezavisnom od locale-a u HotPDF-u
Testiranje putanje prepisivanja protiv object stream-ova
Tri provere hvataju svaki gore opisani neuspeh, i nijedna ne zahteva Acrobat. Prvo, uporedite IndexedObjectCount sa MaterializedObjectCount posle čuvanja; pri punom prepisivanju moraju biti jednaki, a svaki jaz je član koji je ispušten. Drugo, izvucite tekst i nabrojte stablo strukture na oba fajla, ne samo da ih renderujete, da se izgubljeni ActualText ili izgubljeni element strukture pojavi kao diff. Treće, učitajte izlaz svežom instancom i tvrdite da je GetLoadedQuarantinedObjStmCount nula, što ujedno dokazuje da writer nije proizveo kontejner koji čitač ne može da otvori. Kombinacije crypt filter-a koje odlučuju o FReloadObjectStreamsEncrypted izložene su u članku o StmF, StrF i EFF politikama. Strana writer-a u ovoj priči, kako emitovati object stream-ove i kada preferirati inkrementalno ažuriranje nad prepisivanjem, je u vodiču za object stream-ove i inkrementalna ažuriranja
Lenjo učitavanje članova, prolaz razvijanja pre writer-a, karantin dešifrovanja i čuvanje izvornog tokena isporučuju se u HotPDF Delphi Component za Delphi i C++Builder. Stranica proizvoda linkuje referencu API-ja ako želite da ispratite GetLoadedObjectStreamCacheInfo i pristupne metode karantina prema sopstvenom prijemu dokumenata