Quand le HotPDF Delphi Component charge un fichier PDF 1.5 avec LoadFromFile, il n'analyse pas les objets emballés dans les conteneurs /Type /ObjStm. Il enregistre où vit chaque membre compressé et ne l'analyse que quand quelque chose le demande. Cet invariant paresseux est ce qui garde le temps de chargement proportionnel à ce que vous touchez réellement, et c'est aussi la raison pour laquelle une réécriture complète doit faire un travail supplémentaire avant que le moindre octet ne sorte : étendre chaque membre encore non analysé, parce que la réécriture est sur le point de jeter les conteneurs dans lesquels ces membres vivent
Le symptôme qui a motivé cette note est facile à décrire et désagréable à déboguer. Chargez un fichier dont les polices, les espaces de couleur et l'arbre de structure sont dans des object streams, faites-le passer par le couple de génération BeginDoc et EndDoc, et la sortie s'ouvre sans se plaindre. Le nombre de pages est bon, le texte est visible sur les pages que vous vérifiez. Puis un collègue ouvre la page 40 et le corps du texte se rend dans une police substituée, ou la commande Extract Text renvoie du charabia là où un remplacement ActualText se trouvait. Rien n'a planté. Le writer a simplement sérialisé un objet qui n'avait jamais été chargé, et un objet non chargé se sérialise en rien
Que garde réellement LoadFromFile pour un objet compressé ?
Pour chaque entrée de références croisées de type 2, LoadFromFile garde un petit enregistrement dans FCompactObjects : le numéro d'objet, l'index du flux conteneur dans la table des conteneurs, la position du membre à l'intérieur de ce flux, et un pointeur ParsedObject qui commence à nil. Le conteneur lui-même est localisé, déchiffré si le document est chiffré, et gonflé, mais les corps des membres sont laissés sous forme d'octets. ISO 32000-1 §7.5.7 définit la disposition du conteneur qui rend cela possible : un en-tête de paires numéro d'objet et décalage, puis les corps des membres concaténés après /First, de sorte que n'importe quel membre peut être découpé sans toucher à ses voisins
EnsureCompressedObjectLoaded est le seul chemin qui transforme un enregistrement en objet. Il trouve l'enregistrement par numéro d'objet, et si ParsedObject est déjà affecté il renvoie cet objet en cache et compte un cache hit. Sinon il recharge le conteneur s'il avait été évincé, calcule la plage d'octets du membre d'après la table de décalages, remet à l'analyseur une vue sans copie de cette tranche, et stocke le résultat dans l'enregistrement. À partir de là l'objet est indirect, porte son vrai numéro d'objet, et est enregistré dans l'index d'objets du document comme n'importe quel objet analysé depuis le corps du fichier. Le catalogue, le dictionnaire d'informations, la racine de l'arbre de pages et les objets de page passent par ce chemin au chargement, parce que la navigation en a besoin. Les polices, les espaces de couleur, les dictionnaires ExtGState et les éléments de structure non, et ils restent sous forme d'enregistrements jusqu'à ce qu'un rendu de page ou une réécriture les touche
Vous pouvez observer cela de l'extérieur. GetLoadedObjectStreamCacheInfo indique combien de conteneurs existent, combien de membres ont été indexés, et combien d'entre eux ont été analysés jusqu'ici :
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;
Sur un fichier riche en structure, le troisième nombre est une petite fraction du deuxième juste après le chargement. Cet écart est tout l'intérêt du chargement paresseux, et c'est aussi exactement l'ensemble des objets pour lesquels une réécriture complète doit revenir
Pourquoi une réécriture complète perd-elle des polices qu'une sauvegarde incrémentale garde ?
Une réécriture complète jette les conteneurs /ObjStm et /XRef du fichier source et resérialise le graphe d'objets depuis zéro, donc tout membre dont le ParsedObject est encore nil n'a plus de représentation dans la sortie. Une mise à jour incrémentale n'a jamais ce problème, parce qu'elle ajoute de nouveaux objets après les octets d'origine et laisse les anciens conteneurs en place pour que la section de références croisées précédente les adresse. La différence n'est pas dans la façon dont les deux modes traitent les polices. Elle est dans le fait que les conteneurs d'origine survivent ou non pour être lus par la visionneuse suivante
Le correctif vit dans SaveToStream, le sérialiseur que EndDoc pilote, que vous définissiez FileName ou OutputStream. Avant de dispatcher vers une branche de writer, il parcourt FCompactObjects et appelle EnsureCompressedObjectLoaded sur chaque entrée. Si un membre ne peut pas être chargé, la sauvegarde lève une erreur plutôt que de continuer, parce qu'une réécriture qui perd silencieusement un dictionnaire de police est pire qu'une réécriture qui s'arrête. L'expansion doit se situer à ce niveau, au-dessus des branches classique, packed et linéarisée, et au-dessus de l'élagage des flux structurels rechargés par la route linéarisée. Une version antérieure n'étendait les membres qu'à l'intérieur de SaveLoadedDocument, ce qui couvrait le vocabulaire du document chargé et ratait complètement celui de la génération. LoadFromFile suivi de BeginDoc, de modifications de pages et d'EndDoc allait droit au writer avec chaque membre non touché encore non analysé
// Les deux vocabulaires de réécriture étendent maintenant les membres compacts avant tout writer.
// Chemin document chargé :
Pdf.LoadFromFile('quarterly.pdf');
Pdf.SaveLoadedDocument('quarterly-rewritten.pdf');
// Chemin génération sur un fichier chargé :
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 matérialise d'abord chaque entrée FCompactObjects
Les membres en cache gardent ce que vous leur avez fait. Un objet analysé, modifié et marqué sale avant la sauvegarde est renvoyé depuis le cache avec ses modifications, et un membre que vous avez supprimé garde son état de suppression à travers les sauvegardes répétées. La passe d'expansion est idempotente par construction : elle ne fait que remplir des cases nil
Pourquoi les contrôles de pixels sur trois pages ratent le cas ActualText
Les éléments de structure sont l'endroit où ce bug se cache le plus longtemps. Une entrée ActualText sur une séquence de contenu balisé, définie dans ISO 32000-1 §14.9.4, remplace les glyphes pour l'extraction et l'accessibilité mais n'affecte pas le rendu. Si l'élément de structure vit dans un object stream et que la réécriture le perd, la page se dessine toujours correctement, les première, moyenne et dernière pages se comparent pixel pour pixel avec la source, et la régression n'apparaît que quand quelqu'un lance une extraction de texte ou un lecteur d'écran. Un test de réécriture qui ne fait que rendre des pages n'est pas un test de réécriture pour du PDF balisé. Comparez aussi le texte extrait et l'arbre de structure
En quoi un mot de passe utilisateur vide change-t-il le chargement ?
Un mot de passe utilisateur vide signifie quand même que le fichier est chiffré, et les object streams d'un tel fichier sont du texte chiffré jusqu'à ce que la clé de fichier soit retrouvée. ISO 32000-1 §7.6.3.4 Algorithm 2 dérive cette clé du mot de passe, de l'entrée /O, de /P et du premier identifiant de document, et HotPDF doit l'exécuter sur la chaîne vide avant que la passe de type 2 puisse gonfler le moindre conteneur. C'est pourquoi BeginDoc sur un document chiffré chargé appelle DecryptLoadedDocument avec un mot de passe vide avant toute autre chose : le graphe d'objets doit être authentifié et déchiffré avant qu'une réécriture puisse commencer, que l'appelant ait ou non l'intention de protéger la sortie. Le chiffrement de la sortie est une décision séparée, pilotée par les réglages de protection de l'appelant, et BeginDoc restaure ces réglages après la passe de déchiffrement pour qu'une entrée chiffrée ne se transforme pas silencieusement en sortie chiffrée
La politique des conteneurs est lue dans le dictionnaire /Encrypt avant qu'un mot de passe soit essayé. Pour /V 1 et 2, chaque flux est chiffré avec la clé de fichier. Pour les crypt filters, HotPDF résout /StmF à travers /CF : un filtre Identity ou un /CFM valant None signifie des conteneurs en clair, tandis que V2 et AESV2 signifient des conteneurs chiffrés. La réponse atterrit dans FReloadObjectStreamsEncrypted, et elle compte pour un cas précis. Quand les conteneurs sont en clair mais que les chaînes ne le sont pas, les membres portent des chaînes chiffrées qui doivent être déchiffrées individuellement, donc MaterializeMembersOfPlaintextObjectStreams étend chaque membre compact avant la passe de déchiffrement par objet. Il ne fait rien quand la politique n'est pas encore connue, et rien quand les conteneurs eux-mêmes étaient chiffrés, parce que les membres d'un conteneur chiffré ont déjà été déchiffrés avec lui et ne doivent jamais l'être deux fois
Que se passe-t-il quand un conteneur ne peut pas être déchiffré ?
Un conteneur dont le déchiffrement échoue est mis en quarantaine, pas déclaré fatal. La passe de type 2 enregistre une entrée THPDFObjStmQuarantineInfo dans FObjStmQuarantine avec le numéro d'objet du conteneur, un THPDFObjStmQuarantineReason, une chaîne de diagnostic, et la liste des numéros d'objet des membres que les références croisées y avaient routés. osqrDecryptFailed est levé pour quatre situations distinctes : aucun crypt filter n'a pu être résolu, le déchiffrement AES-256 ou AES-GCM a levé, le déchiffrement RC4 ou AES-128 hérité a levé, ou aucune clé de fichier utilisable n'existe. Les conteneurs indépendants continuent de se charger, donc un document avec un conteneur endommagé s'ouvre encore et rend encore chaque page qui n'en dépend pas
La liste de quarantaine survit au repli de l'analyseur. Si le chargement principal des références croisées échoue et que HotPDF reconstruit la table d'objets en balayant le fichier, le drapeau encrypted de la première tentative peut ne pas survivre à cette reconstruction, mais les enregistrements de quarantaine si. C'est pourquoi BeginDoc consulte la liste de quarantaine plutôt que le drapeau encrypted : sur un document chargé, il parcourt FObjStmQuarantine et lève sur la première entrée osqrDecryptFailed, en nommant le conteneur et en demandant un rechargement avec un mot de passe valide. Une réécriture qui continuerait au-delà de ce point écrirait les membres que le conteneur était censé détenir comme des objets vides et rapporterait un succès. Vous pouvez lancer le même contrôle vous-même, plus tôt et avec votre propre politique, via les accesseurs publics :
var
Info: THPDFObjStmQuarantineInfo;
I: Integer;
begin
Pdf.LoadFromFile('vendor-form.pdf'); // mot de passe utilisateur vide
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)]);
// réécriture possible à partir d'ici
end;
Les autres motifs de quarantaine couvrent les échecs non cryptographiques : un conteneur qui n'est pas un flux, un dictionnaire manquant, un /N ou un /First invalide, une taille de flux hors de la plage acceptée, un échec de décompression, un /First pointant au-delà des données, ou un corps de membre qui s'est décodé mais ne s'est pas analysé. Ceux-là méritent d'être journalisés à l'ingestion, puisque chacun nomme les membres exacts qui vous manqueront en aval
Pourquoi une réécriture a-t-elle besoin du token numérique d'origine ?
HotPDF stocke chaque objet numérique comme un Single, et un Single ne peut pas reproduire le texte source d'un nombre réel. ISO 32000-1 §7.3.3 laisse un writer émettre 0.750000, .75 ou 0.75 pour la même valeur, et aucun de ces textes ne survit inchangé à un aller-retour par du binaire 24 bits et un formateur générique. Pire, une valeur comme 0.7 n'est pas représentable dans un Single du tout ; elle s'analyse vers le flottant le plus proche, et reformater ce flottant peut produire 0.69999999 ou un voisin arrondi selon la boucle de chiffres. Sur une couleur de remplissage ou une constante de transparence /CA, c'est une différence d'un cran dans un canal 8 bits, ce qui suffit à faire échouer une comparaison de pixels contre la source et, sur des frontières de dégradé, suffit à se voir
THPDFNumericObject.RememberSourceToken résout cela pour le cas non modifié. L'analyseur l'appelle avec le token brut juste après avoir affecté Value ; la méthode n'accepte que les tokens faits de chiffres, d'au plus un point décimal et d'un signe initial facultatif, et stocke le token avec la valeur à laquelle il correspondait dans FSourceValue. La propriété SourceToken ne renvoie le texte stocké que tant que Value est encore égal à FSourceValue. Modifiez le nombre et le token s'évapore, donc une valeur modifiée passe toujours par le chemin de formatage existant et n'émet jamais de texte périmé. SaveNumericObject teste SourceToken en premier et l'écrit tel quel quand il est présent, puis retombe sur les branches entier, référence d'espace de couleur et fractionnaire uniquement pour les nombres créés ou modifiés en mémoire
L'invariant est petit et mérite d'être énoncé simplement : un nombre que vous n'avez pas touché s'écrit avec les octets avec lesquels il a été lu, et un nombre que vous avez touché est écrit par le formateur propre à HotPDF. Les membres compacts en bénéficient comme les objets du corps, puisque EnsureCompressedObjectLoaded exécute le même analyseur sur la tranche du membre. Le formatage des nombres lui-même, et son indépendance vis-à-vis de la locale du processus, est traité dans l'article sur le formatage invariant des nombres PDF dans HotPDF
Tester un chemin de réécriture face aux object streams
Trois contrôles attrapent toutes les défaillances décrites ci-dessus, et aucun n'a besoin d'Acrobat. D'abord, comparez IndexedObjectCount à MaterializedObjectCount après la sauvegarde ; sur une réécriture complète ils doivent être égaux, et tout écart est un membre qui a été perdu. Ensuite, extrayez le texte et énumérez l'arbre de structure sur les deux fichiers, pas seulement leur rendu, pour qu'un ActualText perdu ou un élément de structure perdu apparaisse comme un diff. Enfin, chargez la sortie dans une instance neuve et vérifiez que GetLoadedQuarantinedObjStmCount vaut zéro, ce qui prouve aussi que le writer n'a pas produit un conteneur que le lecteur ne sait pas ouvrir. Les combinaisons de crypt filters qui décident de FReloadObjectStreamsEncrypted sont exposées dans l'article sur les politiques StmF, StrF et EFF. Le versant writer de cette histoire, comment émettre des object streams et quand préférer une mise à jour incrémentale à une réécriture, se trouve dans le guide sur les object streams et les mises à jour incrémentales
Le chargement paresseux des membres, la passe d'expansion avant le writer, la quarantaine de déchiffrement et la préservation du token source sont tous livrés dans HotPDF Delphi Component pour Delphi et C++Builder. La page produit renvoie à la référence d'API si vous voulez tracer GetLoadedObjectStreamCacheInfo et les accesseurs de quarantaine contre votre propre pipeline d'ingestion