Quando HotPDF Delphi Component carica un file PDF 1.5 con LoadFromFile, non analizza gli oggetti impacchettati dentro i contenitori /Type /ObjStm. Registra dove vive ogni membro compresso e lo analizza solo quando qualcosa lo chiede. È quell'invariante lazy a tenere il tempo di caricamento proporzionale a ciò che tocchi davvero, ed è anche la ragione per cui una riscrittura completa deve fare un lavoro in più prima che esca un byte: espandere ogni membro ancora non analizzato, perché la riscrittura sta per buttare via i contenitori in cui quei membri vivono
Il sintomo che ha motivato questa nota è facile da descrivere e sgradevole da debuggare. Carica un file i cui font, color space e structure tree stanno negli object stream, fagli attraversare la coppia di generazione BeginDoc e EndDoc, e l'output si apre senza lamentele. Il numero di pagine è giusto, il testo è visibile nelle pagine che controlli a campione. Poi un collega apre pagina 40 e il testo del corpo si renderizza con un font sostitutivo, oppure il comando Extract Text restituisce spazzatura dove prima c'era una sostituzione ActualText. Niente è andato in crash. Il writer ha semplicemente serializzato un oggetto che non era mai stato caricato, e un oggetto non caricato si serializza come niente
Cosa tiene davvero LoadFromFile per un oggetto compresso?
Per ogni entry di cross-reference di tipo 2, LoadFromFile tiene un piccolo record in FCompactObjects: il numero di oggetto, l'indice dello stream contenitore nella tabella dei contenitori, la posizione del membro dentro quello stream, e un puntatore ParsedObject che parte da nil. Il contenitore in sé viene individuato, decifrato se il documento è cifrato, e inflatato, ma i body dei membri restano byte. ISO 32000-1 §7.5.7 definisce il layout del contenitore che rende possibile tutto questo: un header di coppie numero di oggetto e offset, poi i body dei membri concatenati dopo /First, così qualunque singolo membro può essere ritagliato senza toccare i vicini
EnsureCompressedObjectLoaded è l'unico percorso che trasforma un record in un oggetto. Trova il record per numero di oggetto, e se ParsedObject è già impostato restituisce quell'oggetto in cache e conta un cache hit. Altrimenti ricarica il contenitore se era stato sfrattato, calcola l'intervallo di byte del membro dalla tabella degli offset, passa al parser una vista zero-copy di quella fetta, e memorizza il risultato di nuovo nel record. Da lì in poi l'oggetto è indiretto, porta il suo vero numero di oggetto, ed è registrato nell'indice degli oggetti del documento come qualunque oggetto analizzato dal corpo del file. Il catalog, il dizionario info, la root dell'albero delle pagine e gli oggetti pagina passano da questo percorso al momento del caricamento perché la navigazione ne ha bisogno. Font, color space, dizionari ExtGState ed elementi di struttura no, e restano record finché un rendering di pagina o una riscrittura non li tocca
Puoi osservarlo dall'esterno. GetLoadedObjectStreamCacheInfo riporta quanti contenitori esistono, quanti membri sono stati indicizzati e quanti di quelli sono stati analizzati finora:
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;
Su un file ricco di struttura il terzo numero è una frazione piccola del secondo subito dopo il caricamento. Quel divario è tutto il senso del caricamento lazy, ed è anche esattamente l'insieme di oggetti per cui una riscrittura completa deve tornare indietro
Perché una riscrittura completa perde font che un salvataggio incrementale conserva?
Una riscrittura completa scarta i contenitori /ObjStm e /XRef del file sorgente e riserializza l'object graph da zero, quindi qualunque membro il cui ParsedObject sia ancora nil non ha più nessuna rappresentazione nell'output. Un aggiornamento incrementale non ha mai questo problema, perché aggiunge nuovi oggetti dopo i byte originali e lascia i vecchi contenitori al loro posto perché la sezione di cross-reference precedente li indirizzi. La differenza non sta in come le due modalità trattano i font. Sta nel fatto che i contenitori originali sopravvivano o meno per essere letti dal prossimo viewer
La correzione vive in SaveToStream, il serializer che EndDoc pilota sia che tu imposti FileName sia OutputStream. Prima di smistare verso qualunque ramo del writer, percorre FCompactObjects e chiama EnsureCompressedObjectLoaded su ogni entry. Se un membro non si riesce a caricare, il salvataggio solleva un'eccezione invece di proseguire, perché una riscrittura che perde in silenzio un dizionario font è peggio di una che si ferma. L'espansione deve stare a quel livello, sopra i rami classic, packed e linearized, e sopra la potatura degli stream strutturali ricaricati del percorso linearized. Una versione precedente espandeva i membri solo dentro SaveLoadedDocument, il che copriva il vocabolario loaded-document e mancava del tutto il vocabolario generation. LoadFromFile seguito da BeginDoc, modifiche alle pagine ed EndDoc andava dritto al writer con ogni membro non toccato ancora non analizzato
// Entrambi i vocabolari di riscrittura ora espandono i membri compact prima che parta il writer.
// Percorso loaded-document:
Pdf.LoadFromFile('quarterly.pdf');
Pdf.SaveLoadedDocument('quarterly-rewritten.pdf');
// Percorso generation su un file caricato:
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 materializza prima ogni entry di FCompactObjects
I membri in cache mantengono qualunque cosa tu abbia fatto loro. Un oggetto che è stato analizzato, modificato e marcato dirty prima del salvataggio viene restituito dalla cache con le sue modifiche, e un membro che hai cancellato mantiene il suo stato di cancellazione attraverso salvataggi ripetuti. La passata di espansione è idempotente per costruzione: riempie solo slot nil
Perché i controlli sui pixel di tre pagine mancano il caso ActualText
Gli elementi di struttura sono il posto dove questo bug si nasconde più a lungo. Un'entry ActualText su una marked-content sequence, definita in ISO 32000-1 §14.9.4, sostituisce i glifi per l'estrazione e l'accessibilità ma non influisce sul rendering. Se l'elemento di struttura vive in un object stream e la riscrittura lo perde, la pagina si disegna comunque correttamente, la prima, quella centrale e l'ultima pagina si confrontano pixel per pixel con la sorgente, e la regressione si vede solo quando qualcuno esegue l'estrazione del testo o uno screen reader. Un test di riscrittura che renderizza solo le pagine non è un test di riscrittura per PDF taggati. Fai il diff anche del testo estratto e dello structure tree
Come cambia il caricamento una user password vuota?
Una user password vuota significa comunque che il file è cifrato, e gli object stream in un file così sono ciphertext finché la file key non viene recuperata. ISO 32000-1 §7.6.3.4 Algorithm 2 deriva quella chiave dalla password, dall'entry /O, da /P e dal primo identificatore di documento, e HotPDF deve eseguirlo sulla stringa vuota prima che la passata di tipo 2 possa inflatare un solo contenitore. Per questo BeginDoc su un documento cifrato caricato chiama DecryptLoadedDocument con una password vuota prima di qualunque altra cosa: l'object graph dev'essere autenticato e decifrato prima che una riscrittura possa iniziare, indipendentemente dal fatto che chi chiama intenda proteggere l'output. La cifratura dell'output è una decisione separata, guidata dalle impostazioni di protezione di chi chiama, e BeginDoc ripristina quelle impostazioni dopo la passata di decrypt, così un input cifrato non si trasforma in silenzio in un output cifrato
La politica dei contenitori viene letta dal dizionario /Encrypt prima che si provi qualunque password. Per /V 1 e 2 ogni stream è cifrato con la file key. Per i crypt filter, HotPDF risolve /StmF attraverso /CF: un filtro Identity o un /CFM pari a None significa contenitori in chiaro, mentre V2 e AESV2 significano contenitori cifrati. La risposta finisce in FReloadObjectStreamsEncrypted, e conta per un caso specifico. Quando i contenitori sono in chiaro ma le stringhe no, i membri portano stringhe cifrate che vanno decifrate singolarmente, quindi MaterializeMembersOfPlaintextObjectStreams espande ogni membro compact prima della passata di decifratura per oggetto. Non fa nulla quando la politica non è ancora nota e non fa nulla quando i contenitori stessi erano cifrati, perché i membri di un contenitore cifrato sono già stati decifrati con esso e non vanno mai decifrati due volte
Cosa succede quando un contenitore non si riesce a decifrare?
Un contenitore che fallisce la decifratura viene messo in quarantena, non è fatale. La passata di tipo 2 registra un'entry THPDFObjStmQuarantineInfo in FObjStmQuarantine con il numero di oggetto del contenitore, un THPDFObjStmQuarantineReason, una stringa diagnostica e la lista dei numeri di oggetto dei membri che la cross-reference vi aveva instradato. osqrDecryptFailed viene sollevato per quattro situazioni distinte: nessun crypt filter risolvibile, la decifratura AES-256 o AES-GCM che ha sollevato un'eccezione, la decifratura legacy RC4 o AES-128 che ha sollevato un'eccezione, o l'assenza totale di una file key utilizzabile. I contenitori indipendenti continuano a caricarsi, quindi un documento con un contenitore danneggiato si apre comunque e renderizza comunque ogni pagina che non dipende da esso
La lista di quarantena sopravvive al fallback del parser. Se il caricamento primario della cross-reference fallisce e HotPDF ricostruisce la tabella degli oggetti scansionando il file, il flag encrypted del primo tentativo può non sopravvivere a quella ricostruzione, ma i record di quarantena sì. Ecco perché BeginDoc controlla la lista di quarantena invece del flag encrypted: su un documento caricato percorre FObjStmQuarantine e solleva un'eccezione sulla prima entry osqrDecryptFailed, nominando il contenitore e chiedendo un ricaricamento con una password valida. Una riscrittura che proseguisse oltre quel punto scriverebbe i membri che il contenitore doveva contenere come oggetti vuoti e riporterebbe successo. Puoi eseguire lo stesso controllo da solo, prima e con la tua politica, tramite gli accessor pubblici:
var
Info: THPDFObjStmQuarantineInfo;
I: Integer;
begin
Pdf.LoadFromFile('vendor-form.pdf'); // user password vuota
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)]);
// da qui la riscrittura è sicura
end;
Le altre ragioni di quarantena coprono i guasti non crittografici: un contenitore che non è uno stream, un dizionario mancante, un /N o /First non valido, una dimensione di stream fuori dall'intervallo accettato, un fallimento di decompressione, un /First che punta oltre i dati, o un body di membro che si è decodificato ma non si è analizzato. Vale la pena registrarli tutti al momento dell'ingest, dato che ognuno nomina gli esatti membri che ti mancheranno a valle
Perché una riscrittura ha bisogno del token numerico originale?
HotPDF memorizza ogni oggetto numerico come Single, e un Single non può riprodurre il testo sorgente di un numero reale. ISO 32000-1 §7.3.3 lascia che un writer emetta 0.750000, .75 o 0.75 per lo stesso valore, e nessuno di quelli sopravvive indenne a un round trip attraverso 24 bit binari e un formatter generico. Peggio, un valore come 0.7 non è rappresentabile affatto in un Single; si analizza nel float più vicino, e riformattare quel float può produrre 0.69999999 o un vicino arrotondato a seconda del ciclo sulle cifre. Su un colore di riempimento o su una costante di trasparenza /CA, è una differenza di un conteggio in un canale a 8 bit, che basta a far fallire un confronto sui pixel contro la sorgente e, sui confini di un gradiente, basta a vedersi
THPDFNumericObject.RememberSourceToken risolve questo per il caso non modificato. Il parser lo chiama con il token grezzo subito dopo aver assegnato Value; il metodo accetta solo token fatti di cifre, al massimo un punto decimale e un segno iniziale opzionale, e memorizza il token insieme al valore a cui corrispondeva in FSourceValue. La proprietà SourceToken restituisce il testo memorizzato solo finché Value è ancora uguale a FSourceValue. Cambia il numero e il token evapora, quindi un valore modificato passa sempre per il percorso di formattazione esistente e non emette mai testo vecchio. SaveNumericObject controlla prima SourceToken e lo scrive alla lettera quando è presente, poi ricade sui rami intero, riferimento a color space e frazionario solo per i numeri che sono stati creati o modificati in memoria
L'invariante è piccolo e vale la pena enunciarlo chiaramente: un numero che non hai toccato viene scritto con i byte con cui è stato letto, e un numero che hai toccato viene scritto dal formatter di HotPDF. I membri compact ne beneficiano allo stesso modo degli oggetti nel body, dato che EnsureCompressedObjectLoaded esegue lo stesso parser sulla fetta del membro. La formattazione dei numeri in sé, e la sua indipendenza dalla locale del processo, è coperta in l'articolo sulla formattazione numerica PDF invariante rispetto alla locale in HotPDF
Testare un percorso di riscrittura contro gli object stream
Tre controlli intercettano ogni guasto descritto sopra, e nessuno richiede Acrobat. Primo, confronta IndexedObjectCount con MaterializedObjectCount dopo il salvataggio; su una riscrittura completa devono essere uguali, e qualsiasi divario è un membro che è stato perso. Secondo, estrai il testo ed enumera lo structure tree su entrambi i file, non solo renderizzali, così un ActualText perso o un elemento di struttura perso saltano fuori come diff. Terzo, carica l'output con un'istanza nuova e verifica che GetLoadedQuarantinedObjStmCount sia zero, il che prova anche che il writer non abbia prodotto un contenitore che il reader non riesce ad aprire. Le combinazioni di crypt filter che decidono FReloadObjectStreamsEncrypted sono illustrate in l'articolo sulle politiche StmF, StrF ed EFF. Il lato writer di questa storia, come emettere gli object stream e quando preferire un aggiornamento incrementale a una riscrittura, è in la guida su object stream e aggiornamenti incrementali
Il caricamento lazy dei membri, la passata di espansione prima del writer, la quarantena di decrypt e la conservazione del token sorgente sono tutti distribuiti in HotPDF Delphi Component per Delphi e C++Builder. La pagina prodotto rimanda al riferimento API se vuoi tracciare GetLoadedObjectStreamCacheInfo e gli accessor della quarantena rispetto alla tua pipeline di ingest