Tehnički članak

Lijeni članovi object streamova pri prepisivanju u Delphiju

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

Kako HotPDF Delphi Component sprema komprimirani član prije nego bude parsiran: zapis FCompactObjects drži broj objekta, indeks kontejnera, indeks člana i nil pokazivač ParsedObject, dok EnsureCompressedObjectLoaded pretvara zapis u registrirani objekt kroz cache hitove, ponovna učitavanja kontejnera, rezanje po tablici offseta i zero-copy parsiranje
LoadFromFile ostavlja tijela članova /ObjStm kao bajtove i parsira ih samo kad čitač zatraži, pa vrijeme učitavanja prati ono što dirate — katalog i stablo stranica stižu rano, dok fontovi, color spaceovi i strukturni elementi ostaju zapisi

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

Gdje sjedi razvijanje prije punog prepisivanja u HotPDF-u: SaveToStream prolazi kroz svaki unos FCompactObjects kroz EnsureCompressedObjectLoaded prije nego se uputi u klasični, pakirani ili linearizirani writer, pa i vokabular SaveLoadedDocument i vokabular LoadFromFile plus BeginDoc plus EndDoc serijaliziraju potpuno parsirane objekte umjesto nil zapisa
Inkrementalno ažuriranje dodaje nakon izvornih bajtova i drži stare kontejnere čitljivima, ali full rewrite ih odbacuje — jedan prolaz razvijanja iznad svake grane writera ono je što čuva neučitani font ili strukturni element od toga da se serijalizira kao ništa
// 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

Kako radi decrypt quarantine u HotPDF-u na učitanom PDF-u: kontejner čije dekriptiranje baci iznimku zapisuje se kao THPDFObjStmQuarantineInfo s razlogom osqrDecryptFailed i brojevima svojih članova, neovisni kontejneri nastavljaju se učitavati, a BeginDoc podiže iznimku na prvom neuspjelom unosu prije nego prepisivanje može prijaviti uspjeh
Zapisi karantene preživljavaju fallback parsera, a BeginDoc ih provjerava po imenu, a ne po zastavici šifriranja, pa se dokument s jednim oštećenim kontejnerom i dalje otvara, dok se put prepisivanja zaustavlja umjesto da piše prazne objekte

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