Kad HotPDF Delphi Component učita PDF 1.5 datoteku s LoadFromFile, ne parsira objekte spakirane unutar /Type /ObjStm kontejnera. Zapisuje gdje živi svaki komprimirani član i parsira ga samo kad nešto zatraži. Ta lijena invarijanta drži vrijeme učitavanja proporcionalnim onome što doista dirate, a ujedno je i razlog zašto full rewrite mora obaviti jedan dodatni posao prije nego ijedan bajt izađe: razviti svaki član koji je još neparsiran, jer će prepisivanje upravo odbaciti kontejnere u kojima ti članovi žive
Simptom koji je potaknuo ovu bilješku lako je opisati i neugodno debugirati. Učitajte datoteku čiji fontovi, color spaceovi i structure tree sjede u object streamovima, provedite je kroz generacijski par BeginDoc i EndDoc, i izlaz se otvara bez prigovora. Broj stranica je točan, tekst je vidljiv na stranicama koje provjerite. Zatim kolega otvori stranicu 40 i tijelo teksta renderira se u zamjenskom fontu, ili naredba Extract Text vraća smeće ondje gdje je prije bila zamjena ActualText. Ništa nije palo. Writer je jednostavno serijalizirao objekt koji nikad nije bio učitan, a neučitani objekt serijalizira se kao ništa
Što LoadFromFile zapravo čuva za komprimirani objekt?
Za svaki cross-reference unos tipa 2 LoadFromFile drži mali zapis u FCompactObjects: broj objekta, indeks streama koji ga sadrži u tablici kontejnera, poziciju člana unutar tog streama i pokazivač ParsedObject koji počinje kao nil. Sam kontejner se locira, dekriptira ako je dokument šifriran, i inflatira, ali tijela članova ostaju kao bajtovi. ISO 32000-1 §7.5.7 definira raspored kontejnera koji to omogućuje: zaglavlje parova broja objekta i offseta, zatim tijela članova spojena nakon /First, pa se svaki pojedini član može izrezati bez diranja svojih susjeda
EnsureCompressedObjectLoaded jedini je put koji zapis pretvara u objekt. Nalazi zapis po broju objekta i, ako je ParsedObject već postavljen, vraća taj cachirani objekt i broji cache hit. Inače ponovno učitava kontejner ako je bio izbačen, izračunava bajtni raspon člana iz tablice offseta, predaje parseru zero-copy pogled na taj isječak i sprema rezultat natrag u zapis. Od tog trenutka objekt je neizravan, nosi svoj pravi broj objekta i registriran je u indeksu objekata dokumenta kao i svaki objekt parsiran iz tijela datoteke. Katalog, info rječnik, korijen stabla stranica i objekti stranica prolaze tim putem pri učitavanju jer ih navigacija treba. Fontovi, color spaceovi, ExtGState rječnici i strukturni elementi ne prolaze, i ostaju zapisi dok ih renderiranje stranice ili prepisivanje ne dotakne
To možete promatrati i izvana. GetLoadedObjectStreamCacheInfo prijavljuje koliko kontejnera postoji, koliko je članova indeksirano i koliko je od njih dosad 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 datoteci s puno strukture treći je broj odmah nakon učitavanja mali dio drugoga. Ta razlika je cijela poanta lijenog učitavanja, a ujedno je točno skup objekata po koje full rewrite mora ponovno doći
Zašto full rewrite ispušta fontove koje inkrementalno spremanje čuva?
Full rewrite odbacuje /ObjStm i /XRef kontejnere izvorne datoteke i ponovno serijalizira objektni graf od nule, pa svaki član čiji je ParsedObject još nil nema nikakav prikaz u izlazu. Inkrementalno ažuriranje nikad nema taj problem, jer dodaje nove objekte nakon izvornih bajtova i ostavlja stare kontejnere na mjestu da ih prethodna cross-reference sekcija može adresirati. Razlika nije u tome kako dva moda tretiraju fontove. Nego u tome preživljavaju li izvorni kontejneri da bi ih sljedeći preglednik mogao čitati
Popravak živi u SaveToStream, serializeru koji EndDoc pokreće bez obzira postavite li FileName ili OutputStream. Prije nego se uputi u bilo koju granu writera, prolazi kroz FCompactObjects i poziva EnsureCompressedObjectLoaded nad svakim unosom. Ako se član ne može učitati, spremanje podiže iznimku umjesto da nastavi, jer je prepisivanje koje tiho ispusti rječnik fonta gore od onoga koje stane. Razvijanje mora sjediti na toj razini, iznad klasične, pakirane i linearizirane grane, i iznad uklanjanja ponovno učitanih strukturnih streamova u lineariziranoj ruti. Ranija verzija razvijala je članove samo unutar SaveLoadedDocument, što je pokrivalo vokabular učitanog dokumenta, a potpuno promašilo generacijski vokabular. LoadFromFile nakon kojeg slijede BeginDoc, uređivanja stranica i EndDoc išli su ravno u writer sa svakim nediranim članom još neparsiranim
// Oba vokabulara prepisivanja sada razvijaju kompaktne članove prije nego se pokrene ijedan writer.
// Put učitanog dokumenta:
Pdf.LoadFromFile('quarterly.pdf');
Pdf.SaveLoadedDocument('quarterly-rewritten.pdf');
// Generacijski put nad učitanom datotekom:
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 materijalizira svaki unos FCompactObjects
Cachirani članovi zadržavaju sve što ste im učinili. Objekt koji je bio parsiran, uređen i označen prljavim prije spremanja vraća se iz cachea sa svojim izmjenama, a član koji ste izbrisali zadržava svoje stanje brisanja kroz ponovljena spremanja. Prolaz razvijanja idempotentan je po konstrukciji: samo popunjava nil slotove
Zašto pikselne provjere na tri stranice promaše slučaj ActualText
Strukturni elementi mjesto su gdje se taj bug skriva najdulje. Unos ActualText na nizu označenog sadržaja, definiran u ISO 32000-1 §14.9.4, zamjenjuje glifove za izvlačenje i pristupačnost, ali ne utječe na renderiranje. Ako strukturni element živi u object streamu i prepisivanje ga izgubi, stranica se i dalje crta ispravno, prva, srednja i posljednja stranica uspoređuju se piksel po piksel s izvorom, a regresija se pokaže tek kad netko pokrene izvlačenje teksta ili screen reader. Test prepisivanja koji samo renderira stranice nije test prepisivanja za označeni PDF. Diffajte i izvučeni tekst i structure tree
Kako prazna korisnička lozinka mijenja učitavanje?
Prazna korisnička lozinka i dalje znači da je datoteka šifrirana, a object streamovi u takvoj datoteci su ciphertext dok se file key ne povrati. ISO 32000-1 §7.6.3.4 Algoritam 2 izvodi taj ključ iz lozinke, unosa /O, /P i prvog identifikatora dokumenta, a HotPDF ga mora pokrenuti nad praznim stringom prije nego prolaz tipa 2 može inflatirati ijedan kontejner. Zato BeginDoc na učitanom šifriranom dokumentu poziva DecryptLoadedDocument s praznom lozinkom prije svega drugoga: objektni graf mora biti autentificiran i dekriptiran prije nego prepisivanje može početi, bez obzira na to namjerava li pozivatelj zaštititi izlaz. Šifriranje izlaza zasebna je odluka, vođena pozivateljevim postavkama zaštite, a BeginDoc vraća te postavke nakon prolaza dekriptiranja, pa se šifrirani ulaz ne pretvara tiho u šifrirani izlaz
Politika kontejnera čita se iz rječnika /Encrypt prije nego se pokuša ijedna lozinka. Za /V 1 i 2 svaki stream šifriran je file keyem. Za crypt filtere HotPDF razrješava /StmF kroz /CF: Identity filter ili /CFM postavljen na None znači kontejnere u čistom tekstu, dok V2 i AESV2 znače šifrirane. Odgovor završava u FReloadObjectStreamsEncrypted, i važan je za jedan konkretan slučaj. Kad su kontejneri u čistom tekstu, a stringovi nisu, članovi nose šifrirane stringove koji se moraju dekriptirati pojedinačno, pa MaterializeMembersOfPlaintextObjectStreams razvija svaki kompaktni član prije prolaza dekriptiranja po objektu. Ne radi ništa kad politika još nije poznata i ništa kad su sami kontejneri bili šifrirani, jer su članovi šifriranog kontejnera već dekriptirani s njim i nikad se ne smiju dekriptirati dvaput
Što se dogodi kad se kontejner ne može dekriptirati?
Kontejner koji ne prođe dekriptiranje stavlja se u karantenu, ali to nije fatalno. Prolaz tipa 2 zapisuje unos THPDFObjStmQuarantineInfo u FObjStmQuarantine s brojem objekta kontejnera, THPDFObjStmQuarantineReason, dijagnostičkim stringom i popisom brojeva objekata članova koje je cross-reference usmjerio u njega. osqrDecryptFailed podiže se u četiri različite situacije: nijedan crypt filter nije bilo moguće razriješiti, AES-256 ili AES-GCM dekriptiranje je bacilo iznimku, naslijeđeno RC4 ili AES-128 dekriptiranje je bacilo iznimku, ili uopće ne postoji upotrebljiv file key. Neovisni kontejneri nastavljaju se učitavati, pa se dokument s jednim oštećenim kontejnerom i dalje otvara i renderira svaku stranicu koja o njemu ne ovisi
Popis karantene preživljava fallback parsera. Ako primarno učitavanje cross-referencea padne i HotPDF rekonstruira tablicu objekata skeniranjem datoteke, zastavica šifriranja iz prvog pokušaja možda neće preživjeti tu rekonstrukciju, ali zapisi karantene hoće. Zato BeginDoc provjerava popis karantene, a ne zastavicu šifriranja: na učitanom dokumentu prolazi kroz FObjStmQuarantine i podiže iznimku na prvom unosu osqrDecryptFailed, imenujući kontejner i tražeći ponovno učitavanje s valjanom lozinkom. Prepisivanje koje bi nastavilo dalje od te točke zapisalo bi članove koje je kontejner trebao držati kao prazne objekte i prijavilo uspjeh. Istu provjeru možete pokrenuti sami, ranije i po vlastitoj politici, kroz javne pristupnike:
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 sigurno prepisivati
end;
Ostali razlozi karantene pokrivaju nekriptografske kvarove: kontejner koji nije stream, nedostajući rječnik, neispravan /N ili /First, veličina streama izvan prihvaćenog raspona, neuspjeh dekompresije, /First koji pokazuje iza podataka, ili tijelo člana koje se dekodiralo, ali nije parsiralo. Njih vrijedi logirati pri ingestu, jer svaki imenuje točno one članove koji će vam kasnije nedostajati
Zašto prepisivanje treba izvorni numerički token?
HotPDF sprema svaki numerički objekt kao Single, a Single ne može reproducirati izvorni tekst realnog broja. ISO 32000-1 §7.3.3 dopušta da writer za istu vrijednost emitira 0.750000, .75 ili 0.75, i nijedan od njih ne preživi round trip kroz 24-bitni binarni zapis i generički formatter nepromijenjen. Još gore, vrijednost poput 0.7 u Single uopće nije predstavljiva; parsira se u najbliži float, a ponovno formatiranje tog floata može dati 0.69999999 ili zaokruženog susjeda, ovisno o petlji znamenki. Na boji ispune ili konstanti prozirnosti /CA to je razlika od jednog koraka u 8-bitnom kanalu, dovoljna da padne pikselna usporedba s izvorom, a na granicama gradijenta i dovoljna da se vidi
THPDFNumericObject.RememberSourceToken rješava to za neizmijenjeni slučaj. Parser ga poziva sa sirovim tokenom odmah nakon dodjele Value; metoda prihvaća samo tokene sastavljene od znamenki, najviše jedne decimalne točke i opcionalnog vodećeg predznaka, i sprema token zajedno s vrijednošću kojoj je odgovarao u FSourceValue. Svojstvo SourceToken vraća spremljeni tekst samo dok je Value još jednak FSourceValue. Promijenite broj i token ispari, pa izmijenjena vrijednost uvijek prolazi postojećim putem formatiranja i nikad ne emitira zastarjeli tekst. SaveNumericObject prvo provjerava SourceToken i zapisuje ga doslovno kad postoji, a na grane za cijele brojeve, reference color spacea i razlomke prelazi samo za brojeve koji su stvoreni ili uređeni u memoriji
Invarijanta je mala i vrijedi je izreći otvoreno: broj koji niste dirali zapisuje se bajtovima kojima je pročitan, a broj koji ste dirali zapisuje HotPDF-ov vlastiti formatter. Kompaktni članovi imaju od toga istu korist kao i objekti iz tijela datoteke, jer EnsureCompressedObjectLoaded vrti isti parser nad isječkom člana. Samo formatiranje brojeva, i njegova neovisnost o localeu procesa, obrađeno je u članku o locale-invariantnom formatiranju PDF brojeva u HotPDF-u
Testiranje puta prepisivanja protiv object streamova
Tri provjere hvataju svaki gore opisani kvar, i nijedna ne treba Acrobat. Prvo, usporedite IndexedObjectCount s MaterializedObjectCount nakon spremanja; pri full rewriteu moraju biti jednaki, a svaka razlika je član koji je ispušten. Drugo, izvucite tekst i nabrojite structure tree na objema datotekama, a ne samo renderirajte ih, pa se izgubljeni ActualText ili izgubljeni strukturni element pojavi kao diff. Treće, učitajte izlaz u svježoj instanci i provjerite da je GetLoadedQuarantinedObjStmCount nula, što ujedno dokazuje da writer nije proizveo kontejner koji čitač ne može otvoriti. Kombinacije crypt filtera koje odlučuju o FReloadObjectStreamsEncrypted izložene su u članku o StmF, StrF i EFF politikama. Strana writera u ovoj priči, kako emitirati object streamove i kad preferirati inkrementalno ažuriranje nad prepisivanjem, nalazi se u vodiču kroz object streamove i inkrementalna ažuriranja
Lijeno učitavanje članova, prolaz razvijanja prije writera, decrypt quarantine i očuvanje izvornog tokena isporučuju se u HotPDF Delphi Component za Delphi i C++Builder. Stranica proizvoda povezuje referencu API-ja ako želite pratiti GetLoadedObjectStreamCacheInfo i pristupnike karantene prema vlastitom ingest pipelineu