Technischer Artikel

Veralteter Text nach dem Bearbeiten: PDFiums FPDF_TEXTPAGE-Cache

Sie rufen AddText auf, um mit PDFiumPas eine Zeile auf eine PDF-Seite zu stempeln, dann sofort FindFirst, um zu bestätigen, dass der Stempel gelandet ist, und die Suche kommt leer zurück. Der Text ist auf der Seite — Acrobat zeigt ihn —, aber PDFiumPas' TPdf-Komponente hält eine separate zwischengespeicherte FPDF_TEXTPAGE-Struktur, einmal aus dem Content-Stream der Seite geparst, und eine Bearbeitung aktualisiert diese Struktur nicht von selbst rückwirkend. Fragen Sie sie ab, bevor sie aufgefrischt wurde, lesen Sie die Seite genau so, wie sie vor Ihrer Änderung aussah, nicht danach

Warum gibt PDFium direkt nach einer Bearbeitung veralteten Text zurück?

PDFiumPas umschließt Googles PDFium-Render-Engine für Delphi und C++Builder, und seine Text- und Bearbeitungsaufrufe erreichen zwei unterschiedliche Subsysteme innerhalb dieser Engine. FPDF_TEXTPAGE gehört zur Leseseite: FPDFText_LoadPage durchläuft den Content-Stream der Seite einmal und baut die Textseite auf — Zeichencodes, Positionen, Font-Metriken, Wortgrenzen —, und PDFiumPas hält diese Struktur zwischengespeichert, solange die Seite geladen bleibt. Bearbeitungsaufrufe wie FPDFPage_InsertObject oder FPDFPage_GenerateContent operieren auf einer völlig anderen Repräsentation, dem Objekt- und Content-Stream-Graphen der Seite, und PDFium schiebt diese Änderungen nicht von sich aus in eine bereits geöffnete Textseite. Sie bei jeder Bearbeitung neu aufzubauen würde Batch-Bearbeitung inakzeptabel langsam machen, sodass das Design diese Kosten stattdessen gegen eine Regel eintauscht — wer auch immer das Handle hält, schließt es nach einer inhaltsändernden Bearbeitung, und die nächste Lesung baut ein frisches auf

Innerhalb von TPdfs Text-Cache: FTextPage, LoadTextPage und UnloadTextPage

TPdf verfolgt das zwischengespeicherte Handle in einem einzigen privaten Feld, FTextPage, und umschließt seinen Lebenszyklus mit zwei Methoden. LoadTextPage prüft, ob FTextPage nil ist, und ruft nur in diesem Fall FPDFText_LoadPage gegen die aktuelle Seite auf; existiert bereits ein Handle, verwendet LoadTextPage es wieder, ohne zu fragen, ob sich die Seite seit seinem Aufbau geändert hat. UnloadTextPage ist die andere Hälfte: Sie schließt das native Handle mit FPDFText_ClosePage, setzt FTextPage zurück auf nil und verwirft auch die zwischengespeicherte Web-Link-Liste sowie jede laufende Find-Sitzung, da beide aus derselben Textseite abgeleitet waren und aus demselben Grund veralten

LoadTextPages Wiederverwendung-ohne-Prüfung-Verhalten ist genau der Grund, warum die Reihenfolge zählt. Jede Textabfrage auf TPdfText, FindFirst, GetWebLinks — läuft zuerst durch LoadTextPage, sodass, solange FTextPage noch das Vor-Bearbeitungs-Handle hält, keiner dieser Aufrufe irgendeine Möglichkeit hat zu wissen, dass eine Änderung passiert ist. Seitennavigation war hier nie das Risiko: UnloadPage, das bei Seitenwechseln, Neu-Laden und Dokumentschließen läuft, hat die Textseite zusammen mit der Seite selbst immer geschlossen. Die offene Frage betraf immer Bearbeitungen, die auf die Seite angewendet werden, auf der Sie noch sitzen

Welche PDFiumPas-Methoden aktualisieren den Cache automatisch?

TPdfs eigene Seitenbearbeitungsmethoden — AddText, SetText, SetTextPositions, AddPath, RemoveObject und InsertFormObjectFromXObject — rufen jeweils UnloadTextPage auf, bevor sie UpdatePage (PDFiums FPDFPage_GenerateContent) aufrufen, um die Änderung in den Content-Stream zu serialisieren. Rufen Sie eine davon auf, baut der allernächste Text-, FindFirst- oder GetWebLinks-Aufruf die Textseite aus dem Inhalt neu auf, so wie er jetzt steht, ohne dass Ihrerseits ein zusätzlicher Aufruf nötig wäre

var
  Pdf: TPdf;
  Index: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'invoice.pdf';
    Pdf.Active := True;
    Pdf.PageNumber := 1;

    Pdf.AddText('Reviewed by J. Alvarez', 'Helvetica', 10, 72, 40, clBlack, 255, 0);
    // AddText already closed the cached text page, so this FindFirst
    // call rebuilds it fresh before it searches
    Index := Pdf.FindFirst('Reviewed by J. Alvarez');
    if Index >= 0 then
      ShowMessage('Stamp confirmed at character ' + IntToStr(Index));
  finally
    Pdf.Free;
  end;
end;

Das Muster, das trotzdem noch bricht: das rohe TextPage-Handle zwischenspeichern

TPdf legt das lebende Handle über eine schreibgeschützte TextPage-Eigenschaft frei, für den seltenen Fall, dass Sie eine FPDFText_*-Funktion aufrufen müssen, die PDFiumPas nicht umschlossen hat. Diese Fluchtluke ist auch der eine Ort, an dem die automatische Invalidierung nicht helfen kann: Sobald Sie den FPDF_TEXTPAGE-Wert aus der Eigenschaft in eine lokale Variable kopieren, hat PDFiumPas keine Möglichkeit zu wissen, dass Sie ihn noch halten, und keine Möglichkeit, Ihre Kopie zu aktualisieren, wenn UnloadTextPage irgendwo anders in Ihrem Code läuft

var
  Pdf: TPdf;
  RawHandle: FPDF_TEXTPAGE;
  StaleCount: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'contract.pdf';
    Pdf.Active := True;
    Pdf.PageNumber := 1;

    RawHandle := Pdf.TextPage;    // FPDFText_LoadPage handle, cached in FTextPage
    Pdf.SetText(0, 'Amended Clause 4.2');
    // SetText already closed RawHandle and set Pdf.TextPage back to nil.
    // Calling any FPDFText_* function against the old value now touches a
    // handle PDFium has already freed — undefined behavior, not a bug you
    // can catch with a nil check
    StaleCount := FPDFText_CountChars(RawHandle);
  finally
    Pdf.Free;
  end;
end;

Ein Handle zu verwenden, nachdem FPDFText_ClosePage darauf gelaufen ist, ist in PDFium selbst undefiniertes Verhalten, keine PDFiumPas-Konvention, die man ignorieren könnte — es kann die zuletzt bekannten Daten zurückgeben, nichts zurückgeben oder den Prozess zum Absturz bringen, und welches davon bei einem gegebenen Build passiert, ist nichts, worauf sich Anwendungscode verlassen sollte. Die sichere Regel ist eng gefasst: Pdf.TextPage frisch lesen, unmittelbar vor dem FPDFText_*-Aufruf, der es braucht, und nie eine Kopie über eine Anweisung hinweg halten, die die Seite bearbeiten könnte

Bearbeitungen bündeln, dann einmal abfragen

Nichts davon bedeutet, dass jeder AddText- oder RemoveObject-Aufruf direkt danach eine defensive Textabfrage braucht, um das Ergebnis zu prüfen. Jede Bearbeitungsmethode bezahlt bereits einmal die Kosten, die Textseite zu schließen; nach jeder einzelnen Bearbeitung innerhalb einer Schleife abzufragen bezahlt diese Kosten ohne Nutzen erneut, da FPDFText_LoadPage bei jedem Lauf den gesamten Content-Stream erneut durchläuft

var
  Pdf: TPdf;
  I: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'watermarked.pdf';
    Pdf.Active := True;
    Pdf.PageNumber := 1;

    // Strip every text object that looks like a draft watermark. Each
    // RemoveObject call already invalidates the cache on its own, so
    // nothing needs refreshing by hand between iterations
    for I := Pdf.ObjectCount - 1 downto 0 do
      if (Pdf.ObjectType[I] = otText) and (Pdf.ObjectBounds[I].Top > 700) then
        Pdf.RemoveObject(I, True);

    // Query once, after the whole batch is done, not once per removal
    if Pdf.FindFirst('DRAFT') < 0 then
      ShowMessage('Watermark cleared');
  finally
    Pdf.Free;
  end;
end;

Dieselbe Bündelungslogik gilt speziell für den Suchzustand. FindNext und FindPrevious setzen eine von FindFirst gestartete Sitzung fort, und diese Sitzung wird zusammen mit allem anderen von UnloadTextPage abgebaut, sodass ein erneuter Aufruf von FindNext nach einer Bearbeitung — statt erneut FindFirst aufzurufen — eine Exception auslöst, statt still eine Suche gegen Inhalt fortzusetzen, der nicht mehr existiert. Behandeln Sie jede Bearbeitung als harte Grenze sowohl für Textinhalt als auch für Suchposition, und lassen Sie ein frisches FindFirst auf der anderen Seite Ihrer Bearbeitungen die Suche wieder aufnehmen

Wo das zur Extraktions- und Annotationsarbeit passt

Reine Textextraktion — den Text einer Seite lesen, ohne etwas zu ändern — läuft nie in irgendetwas davon hinein, weil nichts ein Handle invalidiert, das keine Bearbeitung berührt hat. Wie Text, Zeichenrechtecke und Wortgrenzen auf einer unveränderten Seite funktionieren, behandelt der begleitende Artikel zur Textextraktion mit PDFiumPas, ohne den hier zusätzlich behandelten Textseiten-Cache-Lebenszyklus

Der Cache-Lebenszyklus zählt am meisten in Workflows, die bearbeiten und dann sofort auf das Ergebnis reagieren: eine Korrektur stempeln und danach suchen, einen Absatz schwärzen und bestätigen, dass er weg ist, oder eine Phrase lokalisieren, um eine Markup-Annotation direkt nach dem Einfügen von Text daneben zu verankern. Dieser letzte Fall lohnt es sich, eigens hervorzuheben — Quad-Point-Markup-Annotationen werden aus Zeichenrechtecken positioniert, die von der Textseite gelesen werden, sodass eine aus vor einer Bearbeitung erfassten Koordinaten gebaute Annotation am Ende die falsche Stelle hervorhebt, sobald die Bearbeitung landet

TPdfs Bearbeitungs- und Text-APIs sind Teil der PDFium-Komponente für Delphi und C++Builder, und die Produktseite trägt die vollständige Methodenreferenz für die hier behandelten Bearbeitungs-, Extraktions- und Suchoberflächen