Zwei PDFs von Hand zusammenführen, ein einzelnes Seitenobjekt ins Zieldokument verschieben, und die Kopie läuft direkt in eine Access Violation. PDFlibPas behebt das in CopyForeignObject: Die Routine kopiert ein indirektes Objekt samt seiner gesamten Referenz-Abschlussmenge tief und löst zyklische Rückreferenzen wie /Parent zu null auf, statt zu rekursieren
Warum stürzt das Kopieren einer Seite über Dokumente hinweg ab?
Weil ein PDF-Seitenbaum nur dann ein Baum ist, wenn man ihn abwärts liest. Geht man ihn so durch, wie es ein rekursiver Kopierer tut, und folgt jedem Wert in jedem Wörterbuch, dann reicht das Seitenwörterbuch /Parent heraus, was auf den /Pages-Knoten zeigt, von dem man kam, und dieser Knoten reicht /Kids heraus, was auf die Seite zurückweist. ISO 32000-1 §7.7.3 macht /Parent auf jedem Seitenbaumknoten außer der Wurzel zur Pflicht, das ist also keine fehlerhafte Datei, die man abweisen könnte — es ist die normale Form jedes Dokuments, das Ihnen je unterkommen wird
Die zweite Hälfte des Problems ist die Nummerierung. Indirekte Objekte werden über eine Objektnummer identifiziert, die für eine Datei lokal ist (ISO 32000-1 §7.3.10), sodass ein Objekt, das aus Dokument A nach Dokument B wandert, neu nummeriert werden muss, und jede Referenz darauf innerhalb der kopierten Abschlussmenge genauso, sonst zeigen zwei Referenzen, die auf eine geteilte Schrift zeigten, hinterher auf zwei unverwandte Dinge. Diese Neunummerierung ist dieselbe Arbeit, die ein schneller Merge auf Byte-Ebene macht, und ein Blick auf beide nebeneinander lohnt sich: Byte-Level-Referenz-Shifting für schnelles PDF-Merging löst sie, indem es ganze Dateien übersetzt, während eine Objekt-Ebenen-Kopie sie Kante für Kante lösen muss
Was PDFlibPas CopyForeignObject tatsächlich kopiert
TPDFlib.CopyForeignObject(SourceDocumentID, ObjectNumber) klont ein indirektes Objekt und alles, was von ihm aus erreichbar ist — verschachtelte Wörterbücher, Arrays, Strings, Namen, Zahlen und Streams samt intakter Wörterbücher — in das aktuell ausgewählte Dokument und gibt ein von null verschiedenes Handle auf die neue indirekte Referenz zurück. Quell-Objektnummern werden über eine während des Aufrufs gehaltene Live-Map neu gemappt, sodass ein Objekt, das zweimal in der Abschlussmenge erreicht wird, einmal geklont und zweimal geteilt wird. Null gibt die Routine zurück, ohne zu raisen, wenn die Quell-Dokument-ID unbekannt ist, wenn die Quelle das ausgewählte Dokument selbst ist oder wenn ObjectNumber unter 1 liegt
var
Lib: TPDFlib;
SourceDoc, TargetDoc, Handle: Integer;
begin
Lib := TPDFlib.Create;
try
TargetDoc := Lib.NewDocument;
if Lib.LoadFromFile('source.pdf', '') <> 1 then
Exit; // LoadFromFile liefert 1 bei Erfolg
SourceDoc := Lib.SelectedDocument; // das Laden hat das Geladene selektiert
Lib.SelectDocument(TargetDoc); // die Kopie zielt auf das ausgewählte Dokument
Handle := Lib.CopyForeignObject(SourceDoc, 12);
if Handle = 0 then
raise Exception.Create('cross-document copy rejected');
finally
Lib.Free;
end;
end;
Zwei Details beißen beim ersten Lauf. LoadFromFile antwortet mit 1 oder 0, nicht mit einer Dokument-ID, das Handle, das Sie brauchen, kommt also direkt nach dem Laden aus SelectedDocument; und die Kopie schreibt immer in das Dokument, das SelectDocument zuletzt aktuell gemacht hat, niemals in das, aus dem Sie geladen haben. Intern trägt die Rekursion zudem eine harte Tiefenkappe von 64, ein Rückhalt gegen pathologische Verschachtelung, nicht der Mechanismus, der Zyklen behandelt — die Zyklusbehandlung ist separat und bewusst
Warum bricht eine reservierte Nil-Zuordnung den Zyklus nicht?
Weil Nil in der Mapping-Tabelle zwei verschiedene Dinge auf einmal bedeutet und der Code sie nicht auseinanderhalten kann. Die naheliegende Verteidigung gegen einen Zyklus ist, den Map-Eintrag einzutragen, bevor man in das Objekt rekursiert, sodass alles, was zurückschleift, den Eintrag findet und stoppt. Aber der Eintrag kann das echte Ziel noch nicht halten — das Ziel existiert erst, wenn die Abschlussmenge darunter geschrieben ist — also hält er Nil, und die Suche, die die Rückkante fangen soll, liest Nil und schließt, das Objekt sei nie gemappt worden
// Defekt: ein reserviertes Nil-Ziel ist von „noch nicht gemappt" nicht zu unterscheiden
NewRef := FindMapped(SrcRef.ObjNum);
if not Assigned(NewRef) then
begin
SetLength(Map, Length(Map) + 1);
Map[High(Map)].SourceObjNum := SrcRef.ObjNum;
Map[High(Map)].Target := nil; // reserviert, weiterhin Nil
NewRef := NewObjRef(CloneObject(SrcInd.Obj, Depth + 1));
Map[High(Map)].Target := NewRef; // wird erst auf dem Rückweg zurückgefüllt
end;
Verfolgen Sie das durch die Seiten-Schleife. Der Klon der Seite erreicht /Parent, rekursiert in den /Pages-Knoten, der /Kids erreicht, was in die Seite zurückrekursiert — deren reservierter Eintrag immer noch Nil liest, also wird sie ein zweites Mal geklont, ein drittes, jede Ebene schiebt einen frischen Frame und ein frisches halbfertiges Objekt. Was Sie beobachten, ist nicht einmal ein sauberer Stack Overflow: die äußeren Frames sitzen auf Referenzen, deren Ziele nie zugewiesen wurden, also ist der erste Schreibzugriff durch einen dieser Slots eine Access Violation irgendwo, die nichts an die Seitenkopie erinnert, die sie verursacht hat
Die Lösung: ein expliziter In-Progress-Zustand
Die Reparatur besteht darin, aufzuhören, Nil zu überladen, und die Frage direkt zu stellen. Ein Map-Eintrag, dessen Ziel noch unzugewiesen ist, bedeutet: dieses Objekt wird gerade geklont, und ein InProgress-Prädikat testet genau das, bevor die gewöhnliche Suche läuft. Ist er wahr, ist die Kante ein Zyklus zurück in einen Vorfahren des aktuellen Klons, und PDFlibPas gibt für sie ein Null-Objekt aus, statt ihr zu folgen
// Ein Map-Eintrag mit Nil-Ziel markiert einen laufenden Klon
function InProgress(Num: Integer): Boolean;
var
I: Integer;
begin
Result := False;
for I := 0 to High(Map) do
if (Map[I].SourceObjNum = Num) and (not Assigned(Map[I].Target)) then
Exit(True);
end;
// ... innerhalb von CloneObject, für eine indirekte Referenz:
if InProgress(SrcRef.ObjNum) then
Exit(FStructure.NewNull); // zyklische Rückkante, nicht rekursieren
NewRef := FindMapped(SrcRef.ObjNum);
if not Assigned(NewRef) then
begin
SrcInd := SourceDoc.FindObj(SrcRef.ObjNum, SrcRef.GenNum);
if (not Assigned(SrcInd)) or (not Assigned(SrcInd.Obj)) then
Exit(FStructure.NewNull); // verwaiste Quellreferenz
SetLength(Map, Length(Map) + 1);
Map[High(Map)].SourceObjNum := SrcRef.ObjNum;
Map[High(Map)].Target := nil; // reservieren, dann rekursieren
NewRef := NewObjRef(CloneObject(SrcInd.Obj, Depth + 1));
Map[High(Map)].Target := NewRef; // zurückfüllen
end;
Exit(NewRef);
Das ist nur deshalb gefahrlos verallgemeinerbar, weil eine strukturelle Tatsache über PDF gilt: Zyklen im Objektgraph erscheinen auf Rückverweisen, nicht auf den Inhaltskanten. /Parent im Seitenbaum und /Prev in einer Outline-Kette zeigen aufwärts oder rückwärts auf etwas bereits Besuchtes; die Abschlussmenge einer Schrift, eines Bild-XObjects oder eines Form-XObjects läuft abwärts und terminiert. Eine Kopie eines Font-Deskriptors, eines Farbraums oder eines Shading-Wörterbuchs bleibt also von der Null-Substitution unberührt — nichts in diesen Abschlussmengen trifft jemals auf InProgress. Die Kosten, klar benannt: Die zyklische Kante überlebt die Kopie nicht. Ein so geklontes Seitenwörterbuch kommt mit /Parent als Null-Objekt an, was ISO 32000-1 §7.3.9 einem fehlenden Eintrag gleichsetzt, das heißt, die kopierte Seite ist ein gültiges Objekt, das zu keinem Seitenbaum gehört, bis Sie es selbst in den Ziel-/Pages-Knoten einhängen und /Count korrigieren. Ein kopiertes Outline-Element verliert sein /Prev auf dieselbe Weise und braucht die Geschwisterkette neu aufgebaut. Das ist der ehrliche Handel: CopyForeignObject liefert eine korrekte Abschlussmenge und lässt die strukturelle Neu-Verkettung dem Aufrufer, dieselbe Grenze, innerhalb der auch das Ersetzen von Seiten unter Wahrung der Objektnummern arbeitet
Warum der Map-Eintrag vor NewObjRef reserviert werden muss
Eine naheliegende Alternative würde den ganzen In-Progress-Tanz umgehen: zuerst ein leeres Hüllenobjekt allokieren, seine echte Nummer in der Map registrieren, dann die Hülle füllen, sobald die Kinder geklont sind. Das geht hier nicht, weil TPDFIndObj.Obj schreibgeschützt ist und sein Inhalt nach der Konstruktion nicht ersetzt werden kann — es gibt keine Hülle zum Füllen. Nummer und Inhalt werden von NewObjRef zusammen entschieden, das heißt, der Map-Eintrag muss vor dem rekursiven Aufruf entstehen und nach ihm fertiggestellt werden, und das Intervall zwischen diesen beiden Momenten ist exakt das, was InProgress abdecken muss. Eine Konsequenz, die man kennen sollte, bevor man Ausgaben per Diff vergleicht: Weil NewObjRef läuft, nachdem die Kind-Abschlussmenge geschrieben ist, kommt die Nummerierung im Ziel von unten nach oben zustande, und die Objektnummern spiegeln nicht die Quellreihenfolge. Das Dateiformat kümmert das nicht, aber ein Byte-Vergleich gegen eine von Hand gebaute Erwartung sehr wohl. Lässt ein Lauf Objekte zurück, die Sie bewusst nicht verlinkt haben, sind sie unreferenziert statt korrupt, und das Mark-and-Sweep-Einsammeln unerreichbarer PDF-Objekte ist das Werkzeug, das sie vor dem Speichern ausräumt
Die Regression, die das abdeckt, braucht ein Detail, das Leute überrascht, die Tests gegen TPDFlib schreiben: Der Konstruktor hält bereits ein Standarddokument, DocumentCount startet also bei 1, und ein Zwei-Dokument-Fixture muss >= 2 behaupten, nicht = 2. Neben der erfolgreichen Kopie pinnt der Test die drei Zurückweisungen — eine unbekannte Quell-ID, das ausgewählte Dokument als eigene Quelle und eine Objektnummer null — die alle 0 zurückgeben statt zu raisen, denn eine Merge-Schleife ist ein schlechter Ort, um zu entdecken, dass eine Guard-Klausel wirft
Wo das in eine Merge-Pipeline gehört
Objekt-Ebenen-Kopieren ist der Baustein für den Fall, in dem ganze Dateien zu grob sind: ein Font-Programm aus einer Vorlage heben, ein einzelnes Form-XObject in ein Stempel-Dokument ziehen oder eine Annotation samt Appearance-Streams über Dateien bewegen, ohne den Rest der Seite mitzuschleppen. PDFlibPas legt es als einzelnen Aufruf gegen geladene Dokumente offen, und wie es neben dem Rest der Low-Level-Objekt-API sitzt, zeigt die Referenz der PDFlibPas Delphi PDF Library