Wenn die HotPDF Delphi Component eine PDF-1.5-Datei mit LoadFromFile lädt, parst sie die Objekte nicht, die in /Type /ObjStm-Containern stecken. Sie notiert, wo jedes komprimierte Member lebt, und parst es erst, wenn etwas danach fragt. Diese Lazy-Invariante hält die Ladezeit proportional zu dem, was Sie tatsächlich anfassen, und sie ist auch der Grund, warum ein Full Rewrite eine Extra-Arbeit erledigen muss, bevor irgendein Byte rausgeht: Jedes noch ungeparste Member expandieren, denn das Rewrite wird gleich die Container wegwerfen, in denen diese Member leben
Das Symptom, das diese Notiz motiviert hat, lässt sich leicht beschreiben und unangenehm debuggen. Laden Sie eine Datei, deren Fonts, Farbräume und Structure Tree in Object Streams sitzen, jagen Sie sie durch das BeginDoc- und EndDoc-Generierungspaar, und die Ausgabe öffnet sich ohne Murren. Die Seitenzahl stimmt, Text ist auf den Stichproben-Seiten sichtbar. Dann öffnet ein Kollege Seite 40, und der Fließtext rendert in einem Ersatzfont, oder der Extract-Text-Befehl liefert Müll zurück, wo früher eine ActualText-Ersetzung stand. Nichts ist abgestürzt. Der Writer hat schlicht ein Objekt serialisiert, das nie geladen wurde, und ein ungeladenes Objekt serialisiert sich als nichts
Was behält LoadFromFile tatsächlich für ein komprimiertes Objekt?
Für jeden Type-2-Cross-Reference-Eintrag hält LoadFromFile einen kleinen Record in FCompactObjects: die Objektnummer, den Index des umschließenden Streams in der Container-Tabelle, die Position des Members innerhalb dieses Streams und einen ParsedObject-Pointer, der mit nil startet. Der Container selbst wird lokalisiert, entschlüsselt, falls das Dokument verschlüsselt ist, und inflated, aber die Member-Body bleiben als Bytes liegen. ISO 32000-1 §7.5.7 definiert das Container-Layout, das das möglich macht: ein Header aus Objektnummern- und Offset-Paaren, dann die Member-Body hintereinander nach /First, sodass sich jedes einzelne Member herausschneiden lässt, ohne seine Nachbarn anzufassen
EnsureCompressedObjectLoaded ist der einzige Pfad, der aus einem Record ein Objekt macht. Er findet den Record über die Objektnummer, und ist ParsedObject bereits gesetzt, gibt er das gecachte Objekt zurück und zählt einen Cache-Hit. Andernfalls lädt er den Container neu, falls er verdrängt wurde, berechnet die Byte-Range des Members aus der Offset-Tabelle, reicht dem Parser eine Zero-Copy-Sicht auf diesen Slice und schreibt das Ergebnis in den Record zurück. Von da an ist das Objekt indirekt, trägt seine echte Objektnummer und ist im Objektindex des Dokuments registriert wie jedes Objekt, das aus dem Dateikörper geparst wurde. Der Katalog, das Info-Dictionary, die Seitenbaum-Wurzel und die Seitenobjekte gehen beim Laden durch diesen Pfad, weil die Navigation sie braucht. Fonts, Farbräume, ExtGState-Dictionarys und Structure Elements nicht – sie bleiben Records, bis ein Seitenrendering oder ein Rewrite sie anfasst
Von außen können Sie dem zusehen. GetLoadedObjectStreamCacheInfo meldet, wie viele Container existieren, wie viele Member indexiert wurden und wie viele davon bisher geparst sind:
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;
Bei einer strukturlastigen Datei ist die dritte Zahl direkt nach dem Laden ein kleiner Bruchteil der zweiten. Diese Lücke ist der ganze Sinn von Lazy Loading, und sie ist zugleich exakt die Menge von Objekten, für die ein Full Rewrite zurückgehen muss
Warum fallen bei einem Full Rewrite Fonts weg, die ein inkrementeller Save behält?
Ein Full Rewrite verwirft die /ObjStm- und /XRef-Container der Quelldatei und serialisiert den Objektgraphen von Grund auf neu, also hat jedes Member, dessen ParsedObject noch nil ist, keine Repräsentation mehr in der Ausgabe. Ein inkrementelles Update hat dieses Problem nie, denn es hängt neue Objekte hinter die Originalbytes an und lässt die alten Container an Ort und Stelle, damit die vorige Cross-Reference-Sektion sie adressieren kann. Der Unterschied liegt nicht darin, wie die beiden Modi Fonts behandeln. Er liegt darin, ob die Originalcontainer überleben, um vom nächsten Viewer gelesen zu werden
Der Fix lebt in SaveToStream, dem Serializer, den EndDoc antreibt, egal ob Sie FileName oder OutputStream setzen. Bevor er an irgendeinen Writer-Zweig dispatcht, läuft er durch FCompactObjects und ruft auf jedem Eintrag EnsureCompressedObjectLoaded auf. Lässt sich ein Member nicht laden, wirft der Save eine Exception, statt weiterzumachen, denn ein Rewrite, der stillschweigend ein Font-Dictionary fallen lässt, ist schlimmer als einer, der stoppt. Die Expansion muss auf dieser Ebene sitzen, über den Classic-, Packed- und Linearized-Zweigen und über dem Beschneiden der neu geladenen Strukturstreams auf dem Linearized-Weg. Eine frühere Version expandierte Member nur innerhalb von SaveLoadedDocument, was das Geladen-Dokument-Vokabular abdeckte und das Generierungs-Vokabular vollständig verpasste. LoadFromFile gefolgt von BeginDoc, Seitenbearbeitungen und EndDoc ging direkt an den Writer, mit jedem unberührten Member noch ungeparst
// Beide Rewrite-Vokabulare expandieren jetzt Compact-Member, bevor ein Writer läuft.
// Geladen-Dokument-Pfad:
Pdf.LoadFromFile('quarterly.pdf');
Pdf.SaveLoadedDocument('quarterly-rewritten.pdf');
// Generierungs-Pfad über eine geladene Datei:
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 materialisiert zuerst jeden FCompactObjects-Eintrag
Gecachte Member behalten, was Sie mit ihnen angestellt haben. Ein Objekt, das vor dem Save geparst, bearbeitet und als dirty markiert wurde, kommt mit seinen Bearbeitungen aus dem Cache zurück, und ein Member, das Sie gelöscht haben, behält seinen Löschzustand über wiederholte Saves hinweg. Der Expansionsdurchlauf ist von der Konstruktion her idempotent: Er füllt ausschließlich nil-Slots
Warum Pixelchecks auf drei Seiten den ActualText-Fall verpassen
Structure Elements sind der Ort, an dem sich dieser Bug am längsten versteckt. Ein ActualText-Eintrag an einer Marked-Content-Sequenz, definiert in ISO 32000-1 §14.9.4, ersetzt die Glyphen für Extraktion und Accessibility, beeinflusst das Rendering aber nicht. Lebt das Structure Element in einem Object Stream und verliert das Rewrite es, zeichnet die Seite weiterhin korrekt, die erste, mittlere und letzte Seite stimmen pixel für pixel mit der Quelle überein, und die Regression zeigt sich erst, wenn jemand Textextraktion oder einen Screenreader laufen lässt. Ein Rewrite-Test, der nur Seiten rendert, ist kein Rewrite-Test für getaggtes PDF. Differenzieren Sie auch den extrahierten Text und den Structure Tree
Wie verändert ein leeres Benutzer-Passwort das Laden?
Ein leeres Benutzer-Passwort bedeutet weiterhin, dass die Datei verschlüsselt ist, und Object Streams in einer solchen Datei sind Chiffretext, bis der Datei-Schlüssel wiedergewonnen ist. ISO 32000-1 §7.6.3.4 Algorithmus 2 leitet diesen Schlüssel aus dem Passwort, dem /O-Eintrag, /P und der ersten Dokument-ID ab, und HotPDF muss ihn gegen den leeren String laufen lassen, bevor der Type-2-Durchgang auch nur einen Container inflaten kann. Deshalb ruft BeginDoc auf einem geladenen verschlüsselten Dokument vor allem anderen DecryptLoadedDocument mit leerem Passwort auf: Der Objektgraph muss authentifiziert und entschlüsselt sein, bevor ein Rewrite beginnen kann, ganz gleich, ob der Aufrufer die Ausgabe schützen will. Die Ausgabeverschlüsselung ist eine separate Entscheidung, getrieben von den Schutz-Einstellungen des Aufrufers, und BeginDoc stellt diese Einstellungen nach dem Decrypt-Durchgang wieder her, damit eine verschlüsselte Eingabe nicht stillschweigend zu einer verschlüsselten Ausgabe wird
Die Container-Policy wird aus dem /Encrypt-Dictionary gelesen, bevor irgendein Passwort versucht wird. Für /V 1 und 2 ist jeder Stream mit dem Datei-Schlüssel verschlüsselt. Bei Crypt Filtern löst HotPDF /StmF über /CF auf: Ein Identity-Filter oder ein /CFM von None bedeutet Klartext-Container, während V2 und AESV2 verschlüsselte bedeuten. Die Antwort landet in FReloadObjectStreamsEncrypted, und sie zählt für einen konkreten Fall. Sind Container Klartext, aber Strings nicht, tragen die Member verschlüsselte Strings, die einzeln entschlüsselt werden müssen, also expandiert MaterializeMembersOfPlaintextObjectStreams jedes Compact-Member vor dem Entschlüsselungsdurchgang pro Objekt. Es tut nichts, solange die Policy unbekannt ist, und nichts, wenn die Container selbst verschlüsselt waren, denn Member eines verschlüsselten Containers wurden bereits mit ihm entschlüsselt und dürfen nie doppelt entschlüsselt werden
Was passiert, wenn ein Container nicht entschlüsselt werden kann?
Ein Container, der bei der Entschlüsselung scheitert, wird in Quarantäne genommen, ist also nicht fatal. Der Type-2-Durchgang verzeichnet einen THPDFObjStmQuarantineInfo-Eintrag in FObjStmQuarantine mit der Objektnummer des Containers, einem THPDFObjStmQuarantineReason, einem Diagnose-String und der Liste der Member-Objektnummern, die die Cross-Reference in ihn geroutet hatte. osqrDecryptFailed wird für vier verschiedene Situationen geworfen: Kein Crypt Filter ließ sich auflösen, das AES-256- oder AES-GCM-Decrypt warf, das Legacy-RC4- oder AES-128-Decrypt warf, oder es existiert überhaupt kein brauchbarer Datei-Schlüssel. Unabhängige Container laden weiter, also öffnet ein Dokument mit einem beschädigten Container weiterhin und rendert jede Seite, die nicht von ihm abhängt
Die Quarantäne-Liste überlebt den Parser-Fallback. Scheitert das primäre Cross-Reference-Laden und rekonstruiert HotPDF die Objekttabelle durch Scannen der Datei, überlebt der Encrypted-Flag aus dem ersten Versuch diese Rekonstruktion womöglich nicht, aber die Quarantäne-Einträge schon. Deshalb prüft BeginDoc die Quarantäne-Liste statt des Encrypted-Flags: Auf einem geladenen Dokument läuft er FObjStmQuarantine durch und wirft beim ersten osqrDecryptFailed-Eintrag, benennt den Container und verlangt einen Reload mit gültigem Passwort. Ein Rewrite, der an dieser Stelle vorbeigegangen wäre, hätte die Member, die der Container hätte halten sollen, als leere Objekte geschrieben und Erfolg gemeldet. Dieselbe Prüfung können Sie selbst früher und mit eigener Policy über die öffentlichen Accessoren fahren:
var
Info: THPDFObjStmQuarantineInfo;
I: Integer;
begin
Pdf.LoadFromFile('vendor-form.pdf'); // leeres Benutzer-Passwort
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)]);
// ab hier gefahrlos neu schreiben
end;
Die übrigen Quarantäne-Gründe decken die nicht-kryptografischen Fehlschläge ab: ein Container, der kein Stream ist, ein fehlendes Dictionary, ein ungültiges /N oder /First, eine Streamgröße außerhalb des akzeptierten Bereichs, ein Dekompressionsfehler, ein /First, der hinter die Daten zeigt, oder ein Member-Body, der dekodierte, aber nicht parste. Die lohnen sich beim Ingest zu loggen, denn jeder benennt exakt die Member, die Ihnen nachgelagert fehlen werden
Warum braucht ein Rewrite das ursprüngliche numerische Token?
HotPDF speichert jedes numerische Objekt als Single, und ein Single kann den Quelltext einer reellen Zahl nicht reproduzieren. ISO 32000-1 §7.3.3 lässt einen Writer für denselben Wert 0.750000, .75 oder 0.75 emittieren, und keiner dieser Texte übersteht einen Round Trip durch 24-Bit-Binär und einen generischen Formatter unverändert. Schlimmer: Ein Wert wie 0.7 ist in einem Single überhaupt nicht darstellbar; er parst zum nächsten Float, und diesen Float neu zu formatieren kann 0.69999999 oder einen gerundeten Nachbarn liefern, je nach Ziffernschleife. Bei einer Füllfarbe oder einer /CA-Transparenzkonstante ist das eine Differenz von einem Count in einem 8-Bit-Kanal – genug, um einen Pixelvergleich gegen die Quelle fallen zu lassen und an Gradientengrenzen genug, um es zu sehen
THPDFNumericObject.RememberSourceToken löst das für den unveränderten Fall. Der Parser ruft es mit dem rohen Token direkt nach der Zuweisung von Value auf; die Methode akzeptiert nur Token aus Ziffern, höchstens einem Dezimalpunkt und einem optionalen führenden Vorzeichen und speichert das Token zusammen mit dem Wert, dem es entsprach, in FSourceValue. Die Eigenschaft SourceToken gibt den gespeicherten Text nur zurück, solange Value noch FSourceValue gleich ist. Ändern Sie die Zahl, verdampft das Token, also läuft ein veränderter Wert immer durch den bestehenden Formatierungspfad und emittiert nie veralteten Text. SaveNumericObject prüft zuerst SourceToken und schreibt ihn wörtlich, wenn er da ist, und fällt dann nur für Zahlen, die im Speicher erzeugt oder bearbeitet wurden, durch die Integer-, Farbraum-Referenz- und Bruchzweige durch
Die Invariante ist klein und es wert, klar benannt zu werden: Eine Zahl, die Sie nicht angefasst haben, wird mit den Bytes geschrieben, mit denen sie gelesen wurde, und eine Zahl, die Sie angefasst haben, wird von HotPDFs eigenem Formatter geschrieben. Compact-Member profitieren genauso davon wie Body-Objekte, denn EnsureCompressedObjectLoaded läuft denselben Parser über den Member-Slice. Die Zahlenformatierung selbst und ihre Unabhängigkeit von der Prozess-Locale behandelt der Artikel zur locale-invarianten PDF-Zahlenformatierung in HotPDF
Einen Rewrite-Pfad gegen Object Streams testen
Drei Prüfungen erwischen jeden der oben beschriebenen Fehlschläge, und keine davon braucht Acrobat. Erstens: Vergleichen Sie nach dem Save IndexedObjectCount mit MaterializedObjectCount; bei einem Full Rewrite müssen sie gleich sein, und jede Lücke ist ein Member, das gefallen ist. Zweitens: Extrahieren Sie Text und enumerieren Sie den Structure Tree auf beiden Dateien, statt sie nur zu rendern, damit ein verlorenes ActualText oder ein verlorenes Structure Element als Diff auftaucht. Drittens: Laden Sie die Ausgabe mit einer frischen Instanz und behaupten Sie, dass GetLoadedQuarantinedObjStmCount null ist – das beweist auch, dass der Writer keinen Container produziert hat, den der Reader nicht öffnen kann. Die Crypt-Filter-Kombinationen, die über FReloadObjectStreamsEncrypted entscheiden, liegen im Artikel zu den StmF-, StrF- und EFF-Policies. Die Writer-Seite dieser Geschichte, wie man Object Streams emittiert und wann man ein inkrementelles Update einem Rewrite vorzieht, steht im Leitfaden zu Object Streams und inkrementellen Updates
Lazy Member Loading, der Expansionsdurchlauf vor dem Writer, die Decrypt-Quarantine und die Source-Token-Erhaltung kommen alle in der HotPDF Delphi Component für Delphi und C++Builder daher. Die Produktseite verlinkt die API-Referenz, falls Sie GetLoadedObjectStreamCacheInfo und die Quarantäne-Accessoren gegen Ihre eigene Ingest-Pipeline nachverfolgen wollen