Technischer Artikel

PDFium-Seitenobjekt-Handles veralten nach Transform in Delphi

Wenn FPDFPage_TransFormWithClip eine Seite neu schreibt, beschreibt jedes bereits gehaltene FPDF_PAGEOBJECT-Handle weiterhin das Parsing von vor dem Transform. Die PDFium-Komponente für Delphi und C++Builder löst das innerhalb von TransformPageContent, das die Textseite entlädt, den Inhalt neu generiert und dann die Seite neu lädt, sodass spätere Abfragen die neuen Koordinaten sehen

Das Symptom ist unauffällig. Man wendet eine Skalierung von 0,9 an, um einen Druckrand hinzuzufügen, liest dann PageObjectInfo und erhält genau dieselben Zahlen wie vor dem Aufruf. Keine Exception, kein Fehlercode, nichts im Log. Das ist ein anderer Fehler als die zwischengespeicherte Textseite, beschrieben in dem Artikel zu veralteten Textseiten nach einer Bearbeitung: Dort ist der Cache ein einzelnes FPDF_TEXTPAGE-Handle, das man verwerfen und neu aufbauen kann, hier liegt das Problem in jedem Seitenobjekt-Handle in eigenen Variablen, plus einer Klasse von Gettern, die Fehlschläge über einen Rückgabecode melden, den die meisten Aufrufer wegwerfen

Warum veralten Seitenobjekt-Grenzen ohne Fehlermeldung?

Weil ein Seitenobjekt-Handle ein Zeiger in eine geparste Darstellung eines bestimmten Content-Streams ist, und ein seitenweiter Transform diesen Content-Stream durch einen neuen ersetzt. PDFium durchsucht nicht den eigenen Aufrufstack nach Handles, die es patchen könnte. Es baut einen frischen Objektgraphen und lässt den alten exakt so stehen, wie er war, sodass ein Lesevorgang gegen das alte Handle ein völlig gültiger Lesevorgang einer Struktur ist, die nicht mehr dem entspricht, was die Datei aussagt

ISO 32000-1 §7.8.2 definiert den Content-Stream als die Operatorfolge, die eine Seite zeichnet, und §8.3.3 definiert, wie die aktuelle Transformationsmatrix den Anwenderraum auf den Geräteraum abbildet. Ein Transform auf Seitenebene wird durch Umhüllen und Neuschreiben dieser Operatoren ausgedrückt, nicht durch In-Place-Bearbeitung der Koordinaten pro Objekt. Die von den Objekten getragenen Koordinaten ändern sich also womöglich überhaupt nicht; was sich ändert, ist die beim Zeichnen geltende Matrix. Jedes Handle, das unter der alten Matrix geparst wurde, beantwortet Geometriefragen unter der alten Matrix, und tut das ohne zu murren

Was FPDFPage_TransFormWithClip tatsächlich neu schreibt

Es schreibt die Seite neu, nicht die eigenen Schnappschüsse. FPDFPage_TransFormWithClip nimmt eine FS_MATRIX und ein Clip-Rechteck FS_RECTF entgegen und wendet beides auf den gesamten Seiteninhalt an. Es ist der richtige Aufruf für Ränder, Ausschießskalierung und das Normalisieren einer ungewöhnlich großen Seite auf eine Zielbox. Es ist der falsche Aufruf, wenn man erwartet, dass bestehende Handles mitwandern, und es lohnt sich auch, sich zu merken, dass er nur den Seiteninhalt berührt: Annotationen sind eine separate Schicht und brauchen TransformPageAnnotations, das dieselben sechs Matrixkoeffizienten an FPDFPage_TransformAnnots weiterleitet

var
  Info: TPdfPageObjectInfo;
  Scale: FS_MATRIX;
  Clip: TPdfRectangle;
begin
  Pdf.PageNumber:= 1;
  Info:= Pdf.PageObjectInfo(0);           // snapshot taken before the transform

  Scale.a:= 0.9;   Scale.b:= 0.0;
  Scale.c:= 0.0;   Scale.d:= 0.9;
  Scale.e:= 29.7;  Scale.f:= 42.0;        // 5% margin, A4 in points
  Clip:= Pdf.GetPageBox(pbMedia);
  Pdf.TransformPageContent(Scale, Clip);

  // Info.Bounds still holds pre-transform geometry, and Info.Handle now
  // points into a page that TransformPageContent has already replaced
end;

Die Refresh-Reihenfolge, die TransformPageContent verwendet

Vier Schritte, in dieser Reihenfolge: Textseite entladen, transformieren, Inhalt generieren, Seite neu laden. TPdf.TransformPageContent läuft genau diese Sequenz. Es ruft CheckPageActive auf, kopiert Matrix und Clip in ihre nativen Record-Formen, ruft UnloadTextPage, dann FPDFPage_TransFormWithClip, dann UpdatePage, den Wrapper um FPDFPage_GenerateContent, und schließlich ReloadPage auf

Jeder Schritt verdient seinen Platz. UnloadTextPage läuft zuerst, weil das zwischengespeicherte FPDF_TEXTPAGE Zeichenboxen hält, die unter der alten Matrix berechnet wurden, und es entfernt auch die daraus abgeleitete Weblink-Liste sowie jede laufende Suchsitzung, die darauf aufbaute. FPDFPage_GenerateContent muss vor dem Reload laufen, weil der Transform im speicherinternen Seitenobjekt lebt, bis er zurück in den Content-Stream serialisiert wird, und ein Reload andernfalls den unveränderten Stream erneut parsen würde. ReloadPage schließt mit FPDF_LoadPage gegen den aktuellen Seitenindex ab, was das Einzige ist, das tatsächlich einen frischen Objektgraphen liefert

// After the transform, re-enumerate. Do not reuse anything captured earlier.
var
  I: Integer;
  Info: TPdfPageObjectInfo;
begin
  Pdf.TransformPageContent(Scale, Clip);   // unload text page, transform,
                                           // generate content, reload page
  for I:= 0 to Pdf.ObjectCount- 1 do
  begin
    Info:= Pdf.PageObjectInfo(I);          // handle and bounds from the new parse
    if Info.Bounds.Right> PageWidth then
      Log('object '+ IntToStr(I)+ ' still overflows after scaling');
  end;
end;

Ein Detail in ReloadPage lohnt es nachzuahmen, falls man diese Sequenz jemals selbst schreibt. Es lädt zuerst die neue Seite und übernimmt sie erst danach in das Feld, sodass ein fehlgeschlagenes Seiten-Laden die aktuelle native Seite und alle ihre abgeleiteten Caches intakt lässt, statt in einen halb abgerissenen Zustand zu geraten. Neu laden ist nicht kostenlos – man zahlt für ein vollständiges erneutes Parsen der Seite –, aber es wird einmal pro Transform bezahlt, nicht einmal pro Abfrage, und es gibt keine günstigere korrekte Alternative

Handles nicht über den Reload hinweg tragen

Nach dem Reload sind die alten Handles nicht bloß veraltet, sie sind dangling. Die vorherige FPDF_PAGE wurde geschlossen, und die zu ihr gehörenden FPDF_PAGEOBJECT-Werte sind Zeiger in freigegebenen Speicher. TPdfPageObjectInfo legt das native Handle in seinem Handle-Feld offen, was wirklich nützlich ist, um ein Objekt direkt an einen Low-Level-Aufruf weiterzugeben, und ebenso wirklich gefährlich, es in einem Formularfeld oder einer Liste über eine Operation hinweg zu behalten, die die Seite neu lädt. Man behandelt einen Schnappschuss-Record nur bis zum nächsten Aufruf, der Inhalt neu generiert, als gültig, im selben Geist wie die in den Notizen zu ABI und Speichersicherheit an der PDFium-Grenze besprochenen Eigentumsregeln

Kann ein Getter fehlschlagen und trotzdem wie gültige Daten aussehen?

Ja, und das ist die zweite Hälfte desselben Problems. FPDFPageObj_GetRotatedBounds und FPDFPageObj_GetIsActive sind Out-Parameter-Getter: Sie liefern ein int-Erfolgs-Flag und schreiben die eigentliche Antwort in ein Referenzargument. Beide können FALSE für ein Objekt liefern, das erstellt wurde, dessen Seite aber noch nicht neu geparst wurde. Passiert das, bleibt der Out-Parameter unangetastet, und ein mit Default(TPdfPageObjectInfo) initialisierter Pascal-Record besteht komplett aus Nullen, sodass der Aufrufer ein Viereck mit vier Punkten im Ursprung und ein Active-Flag von False sieht. Ein fehlgeschlagener Aufruf wurde still zu plausibel wirkenden Daten befördert

TPdfPageObjectInfo beantwortet das mit expliziten Sentinels. HasRotatedBounds trägt das Ergebnis des FPDFPageObj_GetRotatedBounds-Aufrufs, HasActiveState trägt das Ergebnis von FPDFPageObj_GetIsActive, und die Geometrie- und Zustandsfelder werden nur geschrieben, wenn der entsprechende Sentinel True ist. Dasselbe Muster wiederholt sich im gesamten Record für die anderen Out-Parameter-Getter, sodass HasMatrix, HasFillColor, HasStrokeColor und HasStrokeWidth alle dasselbe bedeuten: Der native Aufruf war erfolgreich, und das benachbarte Feld hat Bedeutung

Info:= Pdf.PageObjectInfo(I);

if Info.HasRotatedBounds then
  // RotatedBounds is array [1..4] of TPdfPoint, in draw order
  UseQuad(Info.RotatedBounds[1], Info.RotatedBounds[2],
          Info.RotatedBounds[3], Info.RotatedBounds[4])
else
  // the native call failed; fall back to the axis-aligned rectangle
  UseRect(Info.Bounds);

if Info.HasActiveState and (not Info.Active) then
  SkipObject(I);         // genuinely inactive
// if HasActiveState is False, the object state is unknown, not inactive

Das Muster verallgemeinert sich auf jeden PDFium-Getter, der der Konvention aus Rückgabecode plus Out-Parameter folgt, und davon gibt es viele. Faltet ein Wrapper diese Konvention in ein schlichtes Funktionsergebnis zusammen, hat er das einzige Signal weggeworfen, das "die Antwort ist null" von "es gibt keine Antwort" unterscheidet. Ein zusätzliches Boolean pro Feld mitzuführen kostet ein Byte und beseitigt eine ganze Fehlerklasse, bei der ein Default-Record mit einer Messung verwechselt wird

Wo es weiterhin zubeißt

Drei ehrliche Grenzen. Erstens ist der Refresh pro Seite: Transformiert man Seite zwei, bleiben Handles, die man für Seite eins hält, unberührt, aber man hat nun zwei zu unterschiedlichen Zeitpunkten geparste Seiten, und es liegt an einem selbst, sich zu merken, welcher Schnappschuss von welcher Seite stammt. Zweitens ist Indexstabilität über eine Inhaltsregeneration hinweg nicht garantiert – nach dem Reload ist Index 3, was auch immer Index 3 im neuen Parsing ist, sodass man Objekte besser über ihren Typ und ihre Geometrie neu identifiziert, statt anzunehmen, Positionen hätten sich gehalten. Drittens wird das Clip-Rechteck in FPDFPage_TransFormWithClip auf den Seiteninhalt angewendet und ändert die Größe keiner der Page-Boxen; skaliert man Inhalt herunter, um einen Rand zu schaffen, hat die MediaBox weiterhin die Größe, die sie immer hatte, und ein Viewer zeigt das ursprüngliche Blatt mit der darin verkleinerten Zeichnung. Nichts davon ist exotisch – es ist die gewöhnliche Konsequenz einer C-API, die Zeiger in geparsten Zustand herausgibt und die Lebensdauer dem Aufrufer überlässt. Die Lösung ist dieselbe, die überall sonst funktioniert: genau festlegen, wann ein Schnappschuss abläuft, an dieser Grenze auffrischen, und niemals einen fehlgeschlagenen Aufruf als Wert durchgehen lassen

Wer sich allgemeiner mit Matrix-Verhalten befasst, findet die Multiplikationsreihenfolge, die entscheidet, wo ein Transform landet, in dem Artikel zu Prepend, Append und Pivot bei Matrizen. Die hier beschriebenen Transform- und Seitenobjekt-APIs sind Teil der PDFium-Komponente für Delphi und C++Builder, deren Produktseite die vollständige Referenz für den Seitenobjekt-Schnappschuss-Record und seine Sentinel-Felder trägt