In PDFlibPas, der Delphi-PDF-Bibliothek, bekam eine mit MovePage verschobene Seite früher exakt dieselben MediaBox-, CropBox- und Resources-Objekte, die ihr alter Pages-Knoten hielt, ein späteres SetPageBox oder DrawText auf der verschobenen Seite schrieb also still jenen Knoten und alle Geschwister um, die weiterhin von ihm erbten. Seit v3.539.36 bekommt die verschobene Seite eigene Kopien, und eine indirekte Referenz bleibt eine Referenz. Dasselbe Release schließt zwei verwandte Pfade: SetPageBox auf einer indirekten Box, die mehrere Seiten teilen, und CopyPageRanges, das Seiten des Quelldokuments an ihrem Pages-Knoten band, mit der CropBox an der MediaBox
Die Reports, die hierher führen, erwähnen Objektidentität nie. Sie sagen Dinge wie „Ich habe Seite 7 zugeschnitten und die Seiten 8 bis 12 wurden mit zugeschnitten“, oder „Ich habe die CropBox verkleinert und die MediaBox ist mitgewandert“, oder, das verwirrendste, „Ich habe eine Seite in ein neues Dokument kopiert und die Originaldatei hat sich verändert“. Nichts stürzt ab, nichts leakt, und die gespeicherte Datei ist ein völlig valides PDF. Sie enthält nur Geometrie, die niemand bestellt hat
Warum verkleinert SetPageBox auf einer Seite ihre Geschwister mit?
SetPageBox verkleinerte Geschwister mit, weil zwei Einträge des Seitenbaums auf dasselbe In-Memory-Array zeigten und SetPageBox sein Ziel-Array an Ort und Stelle editiert. Jede Seite oder jeder Pages-Knoten, der dieselbe Instanz hielt, sah die Änderung. Drei Codepfade in PDFlibPas erzeugten dieses Teilen vor v3.539.36:
MovePagematerialisiert die vererbbaren Attribute auf der Seite, bevor es sie vom Parent abkoppelt, und es hing dabei die eigenen Objekte des Ahnen an statt Kopien, verschobene Seite und frühere Geschwister teilten sich also ein Box-Array und ein Resources-DictionarySetPageBoxfolgte indirekten Referenzen und editierte das referenzierte Array, eine Datei, in der mehrere Seiten auf ein/MediaBox 11 0 R-Objekt zeigen, bekam also alle diese Seiten mit einem einzigen Aufruf verkleinert, ganz obMovePageje im Spiel war oder nichtCopyPageRangesmaterialisiert geerbte Werte auf der Quellseite, bevor es sie in das Zieldokument klont, und es hing die Pages-Knoten-Instanzen an die Quellseite an, dazu die MediaBox-Instanz selbst als Default-CropBox
Der MovePage-Fall hat eine kurze Vorgeschichte. Vor v3.539.27 nahm MovePage nur /Resources mit, eine unter einen anderen Parent verschobene Seite übernahm also stillschweigend dessen Größe und Rotation. v3.539.27 fixte die fehlende MediaBox, CropBox und Rotate, worauf sich auch CollateDocumentsEx verlässt, wenn es Seiten umsortiert, hängte aber die Werte des Ahnen als geteilte Instanzen an. Genau dieses Fenster schließt v3.539.36. Die SetPageBox- und CopyPageRanges-Pfade sind älter; jeder Build vor v3.539.36 hat sie
Direkte Werte, indirekte Referenzen und die Vererbung von Seitenattributen
Eine korrekte Kopie eines geerbten Seitenattributs dupliziert direkte Werte und belässt indirekte Referenzen als Referenzen, denn das ist genau die Unterscheidung, die ISO 32000-1 selbst zieht. Ein direktes Objekt wie [0 0 400 300], geschrieben innerhalb eines Dictionaries, gehört diesem Dictionary allein. Ein indirektes Objekt, einmal definiert als 11 0 obj und zitiert als 11 0 R, ist per Design geteilt: ISO 32000-1 §7.3.10 macht es von überall in der Datei adressierbar, und jedes 11 0 R bedeutet dasselbe Objekt
Die Vererbung von Seitenattributen, ISO 32000-1 §7.7.3.4, fügt einen dritten Fall hinzu. Resources, MediaBox, CropBox und Rotate dürfen auf einem Pages-Knoten sitzen und gelten für jede Nachfolgeseite, die keine eigenen definiert. Die Seite hält den Wert nicht; sie schlägt ihn über /Parent nach. Diese Nachschlagkette bricht in dem Moment, in dem eine Seite den Parent wechselt, deshalb müssen MovePage und BalancePageTree zuerst die effektiven Werte auf die Seite selbst schreiben. Die Frage ist nur, wie man sie schreibt
Warum ein Objekt-Pool den Fehler versteckt
In PDFlibPas gehört jedes geparste oder erzeugte PDF-Objekt dem TPDFStructure-Pool des Dokuments, und Dictionaries und Arrays speichern schlicht Zeiger auf ihre Einträge. TPDFDictionary.Add notiert den Zeiger und nichts weiter. Eine Instanz an zwei Parent-Container anzuhängen ist also auf jeder Ebene legal, die die Runtime prüfen kann: kein Double Free beim Abbau, kein Referenzzähler, der schiefgehen könnte, keine Exception. Die Serialisierung ist ebenso gnädig, denn jeder Container schreibt den aktuellen Wert der geteilten Instanz inline, und vor jeder Änderung ist die Ausgabe byte für byte das, was eine korrekte Kopie liefern würde
Das Aliasing kommt erst ans Licht, wenn jemand die geteilte Instanz an Ort und Stelle mutiert. SetPageBox tut genau das über einen Rechteck-Wrapper über dem bestehenden Array, und das Zeichnen auf einer Seite tut es am Resources-Dictionary, sobald ein Font oder Bild registriert wird. Die Änderung landet, stillschweigend, in jedem anderen Container, der den Zeiger hält
Wie PDFlibPas v3.539.36 kopiert statt zu teilen
PDFlibPas v3.539.36 fixt das Problem an beiden Enden: Die Materialisierung hängt jetzt Kopien an, und Box-Schreibvorgänge editieren nur noch ein Array, das die Seite besitzt. Jeder Fix deckt einen Fall ab, den der andere nicht kann
Der Materialisierungs-Helfer, PLInheritPageAttributes, hängt jetzt Page.Owner.Decode(Value.Output) an statt Value. Der Roundtrip durch den Serialisierer ist eine plumpe, aber exakte Art, PDF-Semantik gratis zu bekommen. Ein direktes Array oder Dictionary serialisiert zu seinem Literaltext und dekodiert zu einer frischen, unabhängigen Instanz. Eine indirekte Referenz serialisiert zu 11 0 R und dekodiert zu einem neuen Referenzobjekt, das auf dasselbe Objekt 11 zeigt, die Seite verweist also weiterhin auf das geteilte Objekt, statt eine inline Kopie zu erhalten, womit das in v3.539.27 eingeführte Referenzverhalten erhalten bleibt. Die Kopie ist genau so tief wie die direkte Struktur: Alles, was über eine Referenz innerhalb eines kopierten Dictionaries erreichbar ist, bleibt geteilt, so will es das Dateiformat. BalancePageTree ruft denselben Helfer für jede Seite auf, die es umhängt, dort materialisierte Seiten bekommen also ebenfalls getrennte Instanzen
Kopieren allein reicht nicht, denn im Referenzfall zeigt man weiterhin auf ein geteiltes Objekt. Würde SetPageBox dieser Referenz folgen und Objekt 11 editieren, verkleinerte die verschobene Seite wieder den alten Parent und dessen andere Kinder. Der Box-Schreiber wendet deshalb Copy-on-Write an: Er editiert an Ort und Stelle nur, wenn der eigene Eintrag der Seite ein direktes Array ist, und ersetzt eine indirekte oder fehlende Box durch ein neues direktes Array. Objekt 11 bleibt für jede andere Seite unangetastet, die es zitiert
| Codepfad | Vor v3.539.36 | Seit v3.539.36 |
|---|---|---|
MovePage-Materialisierung | Seite hält die eigenen direkten Instanzen des Ahnen | Seite hält dekodierte Kopien; Referenzen bleiben Referenzen |
SetPageBox | Folgt einer Referenz und editiert das geteilte Array | Editiert nur ein direktes Array auf der Seite, schreibt sonst ein neues |
CopyPageRanges-Quellseite | Teilt Pages-Knoten-Boxen; CropBox ist die MediaBox-Instanz | Jeder materialisierte Wert auf der Quellseite ist eine Kopie |
| Default-Boxen beim Klonen von Seitenressourcen | CropBox, BleedBox, TrimBox und ArtBox teilen ein Array | Jede Default-Box bekommt ihr eigenes Array |
Die letzte Zeile ist die latente. Wenn die Bibliothek die Ressourcen einer Seite für Seitenexport oder Zusammenführen klont, füllt sie fehlende CropBox-, BleedBox-, TrimBox- und ArtBox-Einträge auf, und das waren früher dieselbe Array-Instanz. Kein aktueller Aufrufer ließ dieses Aliasing lange genug überleben, um editiert zu werden, aber der nächste hätte es gekonnt. Wie diese Default-Box-Werte gewählt werden, ist ein eigenes Thema, behandelt im PDFlibPas-Leitfaden zu TrimBox-, BleedBox- und CropBox-Defaults
Das MovePage-Aliasing mit einem handgebauten PDF reproduzieren
Am schnellsten prüfen Sie jeden PDFlibPas-Build mit einem kleinen handgeschriebenen PDF, geladen per LoadFromString, wo jede Objektnummer im Voraus bekannt ist. Der Helfer unten schreibt eine klassische Cross-Reference-Tabelle mit korrekt berechneten Byte-Offsets, der Test verlässt sich also nicht auf das Wiederherstellungsverhalten des Parsers für beschädigte Dateien
uses
System.SysUtils, PDFlibrary;
function BuildPdf(const Objects: array of AnsiString): AnsiString;
var
Offsets: array of Integer;
I, XRefPos: Integer;
begin
Result := '%PDF-1.4'#10;
SetLength(Offsets, Length(Objects));
for I := 0 to High(Objects) do
begin
Offsets[I] := Length(Result); // 0-basiertes Byte-Offset von "N 0 obj"
Result := Result + AnsiString(IntToStr(I + 1)) + ' 0 obj'#10 +
Objects[I] + #10'endobj'#10;
end;
XRefPos := Length(Result);
Result := Result + 'xref'#10'0 ' + AnsiString(IntToStr(Length(Objects) + 1)) +
#10'0000000000 65535 f '#10;
for I := 0 to High(Offsets) do // jeder Eintrag ist exakt 20 Bytes
Result := Result + AnsiString(Format('%.10d 00000 n ', [Offsets[I]])) + #10;
Result := Result + 'trailer'#10'<< /Size ' +
AnsiString(IntToStr(Length(Objects) + 1)) + ' /Root 1 0 R >>'#10 +
'startxref'#10 + AnsiString(IntToStr(XRefPos)) + #10'%%EOF'#10;
end;
function StreamObj(const Content: AnsiString): AnsiString;
begin
Result := '<< /Length ' + AnsiString(IntToStr(Length(Content))) +
' >>'#10'stream'#10 + Content + #10'endstream';
end;
Das Testdokument hat zwei Zwischen-Pages-Knoten. Knoten 3 trägt eine indirekte MediaBox (Objekt 11, 400 mal 300 Punkte), eine direkte CropBox und ein direktes Resources-Dictionary und besitzt zwei Seiten. Knoten 4 hat eine Letter-große MediaBox und besitzt die dritte Seite. Seite 1 auf Position 3 zu verschieben hängt sie unter Knoten 4 um, genau die Verschiebung also, die Materialisierung braucht: ohne sie würde die Seite zu einer Letter-Seite
procedure Check(Condition: Boolean; const Msg: string);
begin
if not Condition then
raise Exception.Create(Msg);
end;
procedure CheckMovedPageIsIsolated;
var
Lib: TPDFlib;
FontID: Integer;
begin
Lib := TPDFlib.Create;
try
Check(Lib.LoadFromString(BuildPdf([
'<< /Type /Catalog /Pages 2 0 R >>',
'<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 3 >>',
'<< /Type /Pages /Parent 2 0 R /Kids [5 0 R 6 0 R] /Count 2 ' +
'/MediaBox 11 0 R /CropBox [10 20 390 280] /Resources << >> >>',
'<< /Type /Pages /Parent 2 0 R /Kids [7 0 R] /Count 1 ' +
'/MediaBox [0 0 612 792] >>',
'<< /Type /Page /Parent 3 0 R /Contents 8 0 R >>',
'<< /Type /Page /Parent 3 0 R /Contents 9 0 R >>',
'<< /Type /Page /Parent 4 0 R /Contents 10 0 R >>',
StreamObj('1 w'), StreamObj('2 w'), StreamObj('3 w'),
'[0 0 400 300]']), '') = 1, 'load failed');
Lib.SelectPage(1);
Check(Lib.MovePage(3) = 1, 'MovePage failed');
Lib.SelectPage(3); // die Seite, die wir gerade verschoben haben
Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'inherited MediaBox lost');
Lib.SetPageBox(1, 0, 200, 200, 200); // MediaBox 200 x 200
Lib.SetPageBox(2, 0, 100, 100, 100); // CropBox 100 x 100
FontID := Lib.AddStandardFont(4); // Helvetica
Lib.SelectFont(FontID);
Lib.SetTextSize(12);
Lib.DrawText(20, 20, 'MOVED');
// Den alten Parent ansehen, BEVOR eine andere Seite gewählt wird (siehe unten)
Check(Pos(AnsiString('/Font'), Lib.GetObjectToString(3)) = 0,
'font registered in the old Pages node');
Lib.SelectPage(1); // frühere Seite 2, weiterhin unter Knoten 3
Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'sibling MediaBox changed');
Check(Abs(Lib.GetPageBox(2, 2) - 380) < 0.001, 'sibling CropBox changed');
Check(Pos(AnsiString('400'), Lib.GetObjectToString(11)) > 0,
'shared object 11 was rewritten');
finally
Lib.Free;
end;
end;
GetPageBox(BoxType, Dimension) nimmt Box-Typ 1 für die MediaBox und 2 für die CropBox, und Dimension 2 für die Breite. Beim Default-Ursprung unten links bedeutet SetPageBox(1, 0, 200, 200, 200) links 0, oben 200, 200 breit und 200 hoch. Auf Builds zwischen v3.539.27 und v3.539.35 scheitern die Geschwister-Checks: Die CropBox-Änderung landet im direkten Array von Knoten 3, und die MediaBox-Änderung schreibt Objekt 11 über die Referenz um
Ändert CopyPageRanges das Quelldokument?
Seit v3.539.36 schreibt CopyPageRanges weiterhin auf die Quellseiten, aber jeder Wert, den es schreibt, ist eine getrennte Kopie, spätere Änderungen an der Quelle bleiben also lokal auf der Seite, die Sie editieren. Das Schreiben selbst ist Absicht: Die Quellseite braucht explizite MediaBox, CropBox, Rotate und Resources, bevor ihr Dictionary ins Ziel geklont wird, sonst würde die Kopie alles verlieren, was sie geerbt hat. Umnummerieren und das Kopieren der Seite ins Ziel behandelt dokumentübergreifendes Deep Copy von Objekten in PDFlibPas; dieser Bug saß auf der Quellseite, von der die meisten annehmen, dass eine Kopie sie nur liest
Die Ausgabe hat es nie gezeigt. Geteilt oder kopiert, die materialisierten Werte serialisieren identisch, beide Dokumente wurden also vor und nach dem Fix byte für byte gleich gespeichert. Nur eine Änderung am Quelldokument nach der Kopie enthüllte das Aliasing:
procedure CheckSourceSurvivesCopy;
var
Lib: TPDFlib;
SourceID, TargetID: Integer;
begin
Lib := TPDFlib.Create;
try
Check(Lib.LoadFromString(BuildPdf([
'<< /Type /Catalog /Pages 2 0 R >>',
'<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 ' +
'/MediaBox [0 0 400 300] /Resources << >> >>',
'<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>',
'<< /Type /Page /Parent 2 0 R /Contents 6 0 R >>',
StreamObj('1 w'), StreamObj('2 w')]), '') = 1, 'load failed');
SourceID := Lib.SelectedDocument;
TargetID := Lib.NewDocument; // wird das gewählte Dokument
Check(Lib.CopyPageRanges(SourceID, '1') = 1, 'copy failed');
Lib.SelectDocument(SourceID);
Lib.SelectPage(1);
Lib.SetPageBox(2, 50, 250, 100, 100); // nur die CropBox verkleinern
Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'MediaBox followed CropBox');
Lib.SetPageBox(1, 0, 200, 200, 200);
Lib.SelectPage(2);
Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'sibling page resized');
Lib.SelectDocument(TargetID); // die Kopie behält ihre Originalgröße
Lib.SelectPage(Lib.PageCount);
Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'copied page resized');
finally
Lib.Free;
end;
end;
Vor v3.539.36 erbten hier beide Seiten die direkte MediaBox des Wurzelknotens, die Kopie hängte diese Instanz an Quellseite 1 an und hängte sie erneut als CropBox von Seite 1 an. Die CropBox zu verkleinern verkleinerte also die MediaBox, und die MediaBox zu verkleinern verkleinerte Seite 2 über den Wurzelknoten. Workflows, die Seiten herauskopieren und dann die Quelle weiter editieren, wie Duplex-Scans zu einem PDF zusammenführen, bevor man die Originale zurechtstutzt, sind der Ort, an dem das auftauchte
Warum ist Instanz-Aliasing so schwer zu testen?
Instanz-Aliasing ist schwer zu testen, weil der beobachtbare Effekt drei Schritte in einer bestimmten Reihenfolge braucht: das Aliasing erzeugen, eine Seite mutieren, dann die andere Seite ansehen, bevor irgendetwas anderes sie anfasst. Die meisten Tests tun nur den ersten Schritt und vergleichen gespeicherte Ausgabe, die identisch ist, ob das Aliasing existiert oder nicht
Die Reihenfolgefalle in PDFlibPas ist SelectPage. Das Wählen einer Seite wendet den aktuellen Font über SelectFont erneut an, was diesen Font in den Ressourcen der Seite registriert. Eine Seite ohne eigenes /Resources löst zum Dictionary ihres Parents auf, ihr allein schon zu wählen fügt dem Pages-Knoten also legitim /Font hinzu. Im MovePage-Test oben fügt das Wählen der früheren Seite 2 den Helvetica-Eintrag zu Knoten 3 hinzu, das ist korrektes Verhalten und kein Leak. Deshalb läuft der GetObjectToString(3)-Check vor SelectPage(1); vertauschen Sie die beiden, scheitert der Test an einem gefixten Build
Dieselbe Regel markiert auch, was v3.539.36 bewusst in Ruhe lässt. Eine Ressource auf eine Seite zu schreiben, die ihr Resources-Dictionary erbt, schreibt in das Dictionary des Ahnen, und jedes Geschwister sieht den neuen Eintrag. Das ist Vererbung, wie sie spezifiziert ist, kein Instanz-Teilen, und es ist harmlos, denn das Hinzufügen eines Font- oder Bildnamens zu einem geteilten Dictionary ändert nicht, wie andere Seiten rendern. Wenn eine Seite aufhören soll zu erben, geben Sie ihr zuerst ihr eigenes Resources-Dictionary
Checkliste für PDF-Objektmodell-Code
Die Lektionen verallgemeinern sich auf jedes PDF-Objektmodell, das auf einem Pool und Zeiger-Containern baut, in Delphi oder woanders:
- Beim Materialisieren geerbter Attribute nach ISO 32000-1 §7.7.3.4 direkte Werte tief kopieren und indirekte Referenzen als neue Referenzen auf dasselbe Objekt belassen
- Niemals eine bestehende Instanz per
Addin einen zweiten Container hängen, außer das Teilen ist beabsichtigt und dokumentiert; Besitz durch einen Pool bedeutet, dass die Runtime sich nie beschwert - Nur das an Ort und Stelle editieren, was der aktuelle Knoten als direktes Objekt besitzt; indirekte oder geerbte Werte durch ein frisches direktes Objekt ersetzen (Copy-on-Write)
- Default-Werte, die von einem anderen Eintrag abgeleitet sind, wie eine CropBox aus einer MediaBox, brauchen ihre eigene Instanz
- Aliasing mit Mutieren-dann-Ansehen-Sequenzen am anderen Halter testen und die Reihenfolge der Aufrufe prüfen, die zwischendrin legitim schreiben könnten
- Das Vergleichen gespeicherter Ausgabe beweist hier nichts: geteilte und kopierte Werte serialisieren identisch, bis zur ersten Änderung
- Bei PDFlibPas auf v3.539.36 oder später aktualisieren, wenn Sie
MovePage,CollateDocumentsEx,BalancePageTreeoderCopyPageRangesaufrufen und danach Seitenboxen editieren oder auf Seiten zeichnen
PDFlibPas exponiert Seitenbaum-Editing, dokumentübergreifendes Kopieren und Seitenbox-Steuerung über eine einzige TPDFlib-Klasse für Delphi, C++Builder und Free Pascal. Editionen, Plattformen und die vollständige API-Referenz finden Sie auf der Produktseite der PDFlibPas-Delphi-PDF-Bibliothek