Când HotPDF Delphi Component încarcă un fișier PDF 1.5 cu LoadFromFile, nu parsează obiectele împachetate în containerele /Type /ObjStm. Reține unde trăiește fiecare membru comprimat și îl parsează doar când ceva cere asta. Invariantul acesta leneș este ce ține timpul de încărcare proporțional cu ce atingi de fapt, și este și motivul pentru care o rescriere completă trebuie să facă o treabă în plus înainte să iasă vreun octet: să expandeze fiecare membru încă neparsat, pentru că rescrierea e pe punctul de a arunca containerele în care trăiesc membrii aceia
Simptomul care a motivat nota asta este ușor de descris și neplăcut de depanat. Încărcați un fișier ale cărui fonturi, spații de culoare și arbore de structură stau în object streams, treceți-l prin perechea de generare BeginDoc și EndDoc, iar ieșirea se deschide fără nicio obiecție. Numărul de pagini e corect, textul se vede pe paginile pe care le verificați. Apoi un coleg deschide pagina 40 și textul de corp se randează într-un font substituit, sau comanda Extract Text întoarce gunoi acolo unde obișnuia să fie o înlocuire ActualText. Nimic nu a crăpat. Writer-ul a serializat pur și simplu un obiect care nu a fost niciodată încărcat, iar un obiect neîncărcat se serializează ca nimic
Ce păstrează de fapt LoadFromFile pentru un obiect comprimat?
Pentru fiecare intrare de referință încrucișată de tip 2, LoadFromFile păstrează o mică înregistrare în FCompactObjects: numărul obiectului, indexul stream-ului container în tabelul de containere, poziția membrului în interiorul stream-ului acela și un pointer ParsedObject care pornește de la nil. Containerul însuși este localizat, decriptat dacă documentul este criptat și inflatat, dar corpurile membrilor sunt lăsate ca octeți. ISO 32000-1 §7.5.7 definește aspectul containerului care face asta posibil: un header de perechi număr-de-obiect și offset, apoi corpurile membrilor concatenate după /First, așa că orice membru individual poate fi decupat fără să îi atingi vecinii
EnsureCompressedObjectLoaded este singura cale care transformă o înregistrare într-un obiect. Găsește înregistrarea după numărul obiectului, iar dacă ParsedObject este deja setat, întoarce obiectul acela din cache și numără o lovire de cache. Altfel reîncarcă containerul dacă a fost eliminat, calculează intervalul de octeți al membrului din tabelul de offseturi, îi dă parserului o vedere fără copiere peste felia aceea și stochează rezultatul înapoi în înregistrare. De atunci încolo obiectul este indirect, cară numărul lui real de obiect și este înregistrat în indexul de obiecte al documentului ca orice obiect parsat din corpul fișierului. Catalogul, dicționarul de informații, rădăcina arborelui de pagini și obiectele de pagină trec prin calea asta la încărcare, pentru că navigarea are nevoie de ele. Fonturile, spațiile de culoare, dicționarele ExtGState și elementele de structură nu, și rămân înregistrări până când o randare de pagină sau o rescriere le atinge
Puteți observa asta din exterior. GetLoadedObjectStreamCacheInfo raportează câte containere există, câți membri au fost indexați și câți dintre ei au fost parsați până acum:
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;
Pe un fișier încărcat cu structură, al treilea număr este o fracțiune mică din al doilea imediat după încărcare. Diferența aceea este tot rostul încărcării leneș, și este exact mulțimea de obiecte la care o rescriere completă trebuie să se întoarcă
De ce pierde o rescriere completă fonturi pe care o salvare incrementală le păstrează?
O rescriere completă aruncă containerele /ObjStm și /XRef ale fișierului sursă și reserializează graful de obiecte de la zero, așa că orice membru al cărui ParsedObject este încă nil nu mai are nicio reprezentare în ieșire. O actualizare incrementală nu are niciodată problema asta, pentru că adaugă obiecte noi după octeții originali și lasă containerele vechi la locul lor, ca secțiunea anterioară de referințe încrucișate să le poată adresa. Diferența nu stă în felul în care cele două moduri tratează fonturile. Stă în dacă containerele originale supraviețuiesc ca să fie citite de următorul vizualizator
Reparația stă în SaveToStream, serializerul pe care îl conduce EndDoc fie că setați FileName, fie OutputStream. Înainte de a trimite spre vreo ramură de writer, parcurge FCompactObjects și apelează EnsureCompressedObjectLoaded pe fiecare intrare. Dacă un membru nu poate fi încărcat, salvarea ridică excepție în loc să continue, pentru că o rescriere care scade în tăcere un dicționar de font este mai rea decât una care se oprește. Expandarea trebuie să stea la nivelul acela, deasupra ramurilor classic, packed și linearized, și deasupra eliminării de stream-uri structurale reîncărcate din ruta linearized. O versiune anterioară expanda membrii doar în interiorul lui SaveLoadedDocument, ceea ce acoperea vocabularul de document încărcat și rata complet vocabularul de generare. LoadFromFile urmat de BeginDoc, editări de pagină și EndDoc mergea direct la writer cu fiecare membru neatins încă neparsat
// Ambele vocabularuri de rescriere expandează acum membrii compacți înainte de a rula vreun writer.
// Calea de document încărcat:
Pdf.LoadFromFile('quarterly.pdf');
Pdf.SaveLoadedDocument('quarterly-rewritten.pdf');
// Calea de generare peste un fișier încărcat:
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 materializează întâi fiecare intrare FCompactObjects
Membrii din cache păstrează orice le-ați făcut. Un obiect care a fost parsat, editat și marcat murdar înainte de salvare este întors din cache cu editările lui, iar un membru pe care l-ați șters își păstrează starea de ștergere la salvări repetate. Trecerea de expandare este idempotentă prin construcție: umple mereu doar sloturi nil
De ce ratează verificările de pixeli pe trei pagini cazul ActualText
Elementele de structură sunt locul unde bug-ul acesta se ascunde cel mai mult. O intrare ActualText pe o secvență de conținut marcat, definită în ISO 32000-1 §14.9.4, înlocuiește glifele pentru extragere și accesibilitate, dar nu afectează randarea. Dacă elementul de structură trăiește într-un object stream și rescrierea îl pierde, pagina se desenează tot corect, prima, cea din mijloc și ultima pagină se compară pixel cu pixel cu sursa, iar regresia se arată abia când cineva rulează extragere de text sau un cititor de ecran. Un test de rescriere care doar randează pagini nu este un test de rescriere pentru PDF etichetat. Faceți diff și pe textul extras și pe arborele de structură
Cum schimbă o parolă de utilizator goală încărcarea?
O parolă de utilizator goală înseamnă tot că fișierul este criptat, iar object streams-urile dintr-un astfel de fișier sunt text cifrat până când cheia de fișier este recuperată. ISO 32000-1 §7.6.3.4 algoritmul 2 derivă cheia aceea din parolă, intrarea /O, /P și primul identificator de document, iar HotPDF trebuie să îl ruleze pe șirul gol înainte ca trecerea de tip 2 să poată inflata măcar un container. De aceea BeginDoc pe un document criptat încărcat apelează DecryptLoadedDocument cu o parolă goală înainte de orice altceva: graful de obiecte trebuie autentificat și decriptat înainte ca o rescriere să poată începe, indiferent dacă apelantul intenționează să protejeze ieșirea. Criptarea ieșirii este o decizie separată, condusă de setările de protecție ale apelantului, iar BeginDoc restaurează setările acelea după trecerea de decriptare, ca o intrare criptată să nu se transforme în tăcere într-o ieșire criptată
Politica de containere este citită din dicționarul /Encrypt înainte de a se încerca vreo parolă. Pentru /V 1 și 2, fiecare stream este criptat cu cheia de fișier. Pentru filtrele de criptare, HotPDF rezolvă /StmF prin /CF: un filtru Identity sau un /CFM de None înseamnă containere în clar, în timp ce V2 și AESV2 înseamnă containere criptate. Răspunsul ajunge în FReloadObjectStreamsEncrypted și contează pentru un caz anume. Când containerele sunt în clar, dar șirurile nu, membrii cară șiruri criptate care trebuie decriptate individual, așa că MaterializeMembersOfPlaintextObjectStreams expandează fiecare membru compact înaintea trecerii de decriptare per obiect. Nu face nimic când politica nu este încă cunoscută și nimic când containerele însele au fost criptate, pentru că membrii unui container criptat au fost deja decriptați odată cu el și nu trebuie niciodată decriptați a doua oară
Ce se întâmplă când un container nu poate fi decriptat?
Un container care eșuează la decriptare este pus în carantină, nu este fatal. Trecerea de tip 2 înregistrează o intrare THPDFObjStmQuarantineInfo în FObjStmQuarantine cu numărul de obiect al containerului, un THPDFObjStmQuarantineReason, un șir de diagnostic și lista numerelor de obiect ale membrilor pe care referința încrucișată le rutase în el. osqrDecryptFailed apare în patru situații distincte: nu s-a putut rezolva niciun filtru de criptare, decriptarea AES-256 sau AES-GCM a aruncat excepție, decriptarea moștenită RC4 sau AES-128 a aruncat excepție, sau nu există deloc o cheie de fișier utilizabilă. Containerele independente continuă să se încarce, așa că un document cu un container deteriorat se deschide tot și randează în continuare fiecare pagină care nu depinde de el
Lista de carantină supraviețuiește căderii pe plan secund a parserului. Dacă încărcarea principală a referințelor încrucișate eșuează și HotPDF reconstruiește tabelul de obiecte scanând fișierul, flag-ul de criptare din prima încercare poate să nu supraviețuiască reconstrucției aceleia, dar înregistrările de carantină da. De aceea BeginDoc verifică lista de carantină, nu flag-ul de criptare: pe un document încărcat parcurge FObjStmQuarantine și ridică excepție la prima intrare osqrDecryptFailed, numind containerul și cerând o reîncărcare cu o parolă validă. O rescriere care ar fi mers mai departe de acel punct ar fi scris membrii pe care containerul trebuia să îi țină ca obiecte goale și ar fi raportat succes. Puteți rula aceeași verificare singur, mai devreme și cu propria politică, prin accesorii publice:
var
Info: THPDFObjStmQuarantineInfo;
I: Integer;
begin
Pdf.LoadFromFile('vendor-form.pdf'); // parolă de utilizator goală
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)]);
// sigur de rescris de aici înainte
end;
Celelalte motive de carantină acoperă eșecurile ne-criptografice: un container care nu este un stream, un dicționar lipsă, un /N sau /First invalid, o dimensiune de stream în afara intervalului acceptat, un eșec de decomprimare, un /First care arată dincolo de date sau un corp de membru care s-a decodat, dar nu s-a parsat. Merită să le logați la ingestie, pentru că fiecare numește exact membrii care vă vor lipsi în aval
De ce are nevoie o rescriere de tokenul numeric original?
HotPDF stochează fiecare obiect numeric ca Single, iar un Single nu poate reproduce textul sursă al unui număr real. ISO 32000-1 §7.3.3 lasă un writer să emită 0.750000, .75 sau 0.75 pentru aceeași valoare, iar niciuna dintre ele nu supraviețuiește neschimbată unui dus-întors prin binar pe 24 de biți și un formator generic. Mai rău, o valoare ca 0.7 nu este deloc reprezentabilă într-un Single; se parsează la float-ul cel mai apropiat, iar reformatarea acelui float poate produce 0.69999999 sau un vecin rotunjit, în funcție de bucla de cifre. Pe o culoare de umplere sau o constantă de transparență /CA, asta este o diferență de o unitate într-un canal pe 8 biți, destul ca să pice o comparație de pixeli cu sursa și, pe granițe de gradient, destul ca să se vadă
THPDFNumericObject.RememberSourceToken rezolvă asta pentru cazul nemodificat. Parserul îl apelează cu tokenul brut imediat după ce atribuie Value; metoda acceptă doar tokenuri făcute din cifre, cel mult un punct zecimal și un semn opțional la început, și stochează tokenul împreună cu valoarea căreia îi corespundea în FSourceValue. Proprietatea SourceToken întoarce textul stocat doar cât timp Value este încă egal cu FSourceValue. Schimbați numărul și tokenul se evaporă, așa că o valoare modificată trece mereu prin calea de formatare existentă și nu emite niciodată text perimat. SaveNumericObject verifică întâi SourceToken și îl scrie întocmai când există, apoi cade pe ramurile de întreg, referință de spațiu de culoare și fracție doar pentru numerele care au fost create sau editate în memorie
Invariantul este mic și merită spus fără ocolișuri: un număr pe care nu l-ați atins este scris cu octeții cu care a fost citit, iar un număr pe care l-ați atins este scris de formatorul propriu al HotPDF. Membrii compacți beneficiază de asta la fel cum beneficiază obiectele din corpul fișierului, pentru că EnsureCompressedObjectLoaded rulează același parser peste felia de membru. Formatarea numerelor în sine, și independența ei de locale-ul procesului, sunt acoperite în articolul despre formatarea invariantă a numerelor PDF în HotPDF
Testarea unei căi de rescriere împotriva object streams-urilor
Trei verificări prind fiecare eșec descris mai sus și niciuna nu are nevoie de Acrobat. Întâi, comparați IndexedObjectCount cu MaterializedObjectCount după salvare; la o rescriere completă trebuie să fie egale, iar orice diferență este un membru care a fost scăpat. Al doilea, extrageți text și enumerați arborele de structură pe ambele fișiere, nu doar randați-le, ca un ActualText pierdut sau un element de structură pierdut să apară ca diff. Al treilea, încărcați ieșirea cu o instanță nouă și asertați că GetLoadedQuarantinedObjStmCount este zero, ceea ce dovedește și că writer-ul nu a produs un container pe care cititorul nu îl poate deschide. Combinațiile de filtre de criptare care decid FReloadObjectStreamsEncrypted sunt expuse în articolul despre politicile StmF, StrF și EFF. Partea de writer a poveștii, cum se emit object streams și când să preferați o actualizare incrementală în locul unei rescrieri, este în ghidul despre object streams și actualizări incrementale
Încărcarea leneșă de membri, trecerea de expandare dinaintea writer-ului, carantina de decriptare și păstrarea tokenului sursă sunt toate livrate în HotPDF Delphi Component pentru Delphi și C++Builder. Pagina de produs leagă referința de API dacă vreți să urmăriți GetLoadedObjectStreamCacheInfo și accesoriile de carantină împotriva propriei conducte de ingestie