Technischer Artikel

Annotation-Appearance-Roundtrips in Delphi mit PDFium

In PDFium Component vor v3.121.1 konnte das Lesen einer Annotation über TPdf.Annotation[] und das Zurückzuweisen des Records leere /R- und /D-Einträge zu ihrem /AP-Appearance-Dictionary hinzufügen, selbst wenn das Original nur /N trug. PDF/A-Validatoren weisen dieses Dictionary zurück. Seit v3.121.1 meldet der Getter nur noch eine Appearance, die er tatsächlich gelesen hat, also schreibt ein unveränderter Roundtrip nichts Neues. Der Fehler lohnt sich im Detail anzuschauen, denn der übliche Auslöser ist ein Fix, der eine Datei konformer machen sollte, nicht weniger

Diagramm des PDFium-Component-Annotation-Roundtrips, wo das Hinzufügen von afPrint über TPdf.Annotation[] und SetAnnotationData zusätzlich leere /R- und /D-Streams über FPDFAnnot_SetAP schreibt und ein PDF/A-sauberes Appearance-Dictionary in eines verwandelt, das veraPDF zurückweist, bis v3.121.1 nur die tatsächlich gelesenen Appearances meldet
Eine Annotation zu lesen und unverändert zurückzuschreiben fügte früher leere Rollover- und Down-Appearance-Streams hinzu, und genau das scheitert an PDF/A, nicht das Print-Flag, das Sie hinzufügen wollten

Was geht schief, wenn Sie eine Annotation unverändert zurückschreiben?

Die kurze Antwort: Die Annotation gewinnt Appearance-Streams, die sie nie hatte, und eine Datei, die vor Ihrer Bearbeitung die PDF/A-Validierung bestand, fällt danach durch. Das typische Szenario läuft so: Ein Kundenarchiv kommt mit Square- und Text-Annotationen ohne Print-Flag, PDF/A verlangt, dass jede Annotation gedruckt wird, also laufen Sie über die Seiten, fügen afPrint hinzu und weisen jeden Record zurück. Nichts in diesem Code fasst Appearances an. Der Record aus TPdf.Annotation[] ist ein TPdfAnnotation, und SetAnnotationData schreibt jedes Feld, dessen Has*-Sentinel gesetzt ist – genau so sollen die HasContents / ContentsText-Paare arbeiten. Das Problem war, dass der Getter HasAppearanceRollover und HasAppearanceDown mit leeren Strings auf True setzte für Modi, die nicht existierten, und der Setter pflichtbewusst zwei leere Streams schrieb:

procedure MarkAnnotationsPrintable(const FileName: string);
var
  Pdf: TPdf;
  PageNo, I: Integer;
  A: TPdfAnnotation;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.Active := True;
    for PageNo := 1 to Pdf.PageCount do
    begin
      Pdf.PageNumber := PageNo;
      for I := 0 to Pdf.AnnotationCount - 1 do
      begin
        A := Pdf.Annotation[I];
        if not (afPrint in A.Flags) then
        begin
          A.Flags := A.Flags + [afPrint] - [afHidden, afInvisible, afNoView];
          // Vor v3.121.1 schrieb diese Zuweisung zusätzlich leere /AP/R- und
          // /AP/D-Streams, wenn die Quell-Annotation nur /AP/N hatte
          Pdf.Annotation[I] := A;
        end;
      end;
    end;
    Pdf.SaveAs(ChangeFileExt(FileName, '.printable.pdf'));
  finally
    Pdf.Free;
  end;
end;

ISO 32000-1 §12.5.5 definiert das Appearance-Dictionary mit drei Einträgen: /N für die normale Appearance, /R für Rollover und /D für Down. /R und /D sind optional, und wenn sie fehlen, fällt ein Viewer auf /N zurück. Ein leerer /R-Stream ist aber nicht abwesend. Er ist ein gültiger Stream, der nichts malt, also zeigt ein Viewer, der Rollover-Appearances ehrt, in dem Moment, in dem der Zeiger über die Annotation wandert, ein leeres Rechteck. PDF/A ist noch strenger: ISO 19005-1 (mit Corrigendum 2) und ISO 19005-2 / 19005-3 erlauben in einem Annotation-Appearance-Dictionary nur /N. veraPDF meldet die zurückgelaufene Datei unter Regel 6.5.3-4 für PDF/A-1 und Regel 6.3.3-2 für PDF/A-2 und PDF/A-3, und das eingebaute TPdf.ValidatePdfA listet sie als pvaiAnnotationApDictViolation. Die Bearbeitung, die das Print-Flag hinzufügte, um einer Klausel des Standards zu genügen, brach eine andere

Das Annotation-Appearance-Dictionary aus ISO 32000-1 mit normal-, rollover- und down-Einträgen: PDFium liefert 2 Bytes für einen fehlenden Stream wie für einen vorhandenen leeren, beide lesen sich über TPdf als ohne Inhalt zurück, und nur ein Byte-Level-Check wie TPdf.ValidatePdfA findet den leeren Stream, den PDF/A verbietet
Ein fehlendes /R fällt auf /N zurück; ein leeres /R malt ein leeres Rechteck und fällt trotzdem durch PDF/A, und über den Record sind beide ununterscheidbar

Warum liefert FPDFAnnot_GetAP für eine fehlende Appearance 2?

PDFium liefert aus FPDFAnnot_GetAP nie null, selbst wenn der angefragte Appearance-Stream nicht existiert. Die Funktion folgt dem üblichen PDFium-Zwei-Aufrufe-Muster: nil-Buffer übergeben, um die benötigte Größe in Bytes zu bekommen, allokieren, dann erneut aufrufen, um UTF-16LE-Text zu kopieren. Die Größe schließt stets den UTF-16-Terminator ein, also meldet ein fehlender Stream 2 Bytes, ein leerer String plus Terminator. Der Getter vor v3.121.1 prüfte ByteLength >= SizeOf(FPDF_WCHAR), eine Prüfung, die jeder Aufruf passiert, also kehrten alle drei HasAppearance*-Flags für jede Annotation mit irgendeiner Appearance als True zurück. Ein Roundtrip durch den Record bat FPDFAnnot_SetAP dann, für jeden Modus einen leeren String zu speichern, und PDFium erzeugte den Stream dafür. Keine Exception, keine Warnung, und die sichtbare Seite sah identisch aus – deshalb tauchte der Defekt in einem veraPDF-Fixture auf und nicht in einem Viewer

Wie FPDFAnnot_GetAP in PDFium einen fehlenden Appearance-Stream meldet: Das Zwei-Aufrufe-Muster liefert stets mindestens zwei Bytes für den UTF-16-Terminator, das alte Gate gegen SizeOf(FPDF_WCHAR) passierte jeder Aufruf und setzte alle HasAppearance-Sentinels auf true, und das v3.121.1-Gate verlangt mehr als den Terminator plus eine gerade Bytezahl
Zwei Bytes sind der kodierte leere String, kein Beweis, dass eine Appearance existiert; der korrigierte Getter behandelt alles bis hinauf zur Terminator-Länge als keinen Inhalt, und das Zurückschreiben bleibt still

Wie v3.121.1 entscheidet, dass eine Appearance existiert

ReadAppearance, der Helfer in GetPageAnnotation, der AppearanceNormal, AppearanceRollover und AppearanceDown füllt, hält ein Ergebnis jetzt nur dann für Inhalt, wenn es mindestens ein Zeichen jenseits des Terminators trägt. Der erste Aufruf muss mehr als SizeOf(FPDF_WCHAR) Bytes und eine gerade Bytezahl liefern, denn eine ungerade Länge kann kein UTF-16 sein. Der zweite Aufruf, der den Text tatsächlich kopiert, wird erneut validiert: Eine zurückgegebene Länge von 2 oder weniger oder eine größere als der zugewiesene Buffer setzt HasValue auf False und lässt den String leer. Auf der Schreibseite hat sich nichts geändert. SetAnnotationData ruft FPDFAnnot_SetAP weiterhin nur für Modi auf, deren HasAppearance*-Flag True ist, also schreibt ein Record, der aus einer Annotation mit nur /N gelesen wurde, jetzt auch nur /N zurück. Das Regression-Fixture deckt beide Richtungen ab: Eine Square-Annotation mit normaler Appearance, gelesen und unverändert zurückgeschrieben, besteht PDF/A-1b, PDF/A-2b und PDF/A-3b, während dieselbe Annotation ohne Print-Flag an der erwarteten Flag-Regel scheitert und an nichts anderem

Fehlende und leere Streams sehen identisch aus, der Getter bleibt konservativ

Die native API kann einen fehlenden Appearance-Stream nicht von einem unterscheiden, der existiert, aber leer ist, und PDFium Component behauptet das Gegenteil nicht. Beide Fälle liefern dieselben 2 Bytes aus FPDFAnnot_GetAP, also lesen beide als HasAppearanceRollover = False mit leerem AppearanceRollover zurück. Das hat zwei Konsequenzen, um die Sie Ihr Design herumbauen sollten. Erstens bedeutet ein False-Sentinel „es wurde kein Inhalt gelesen, ein Zurückschreiben lässt diesen Modus in Ruhe“, nicht „der /R-Schlüssel fehlt im Dictionary“. Zweitens kann der Record einen leeren Stream, der bereits in der Datei steckt, nicht erkennen: Ein von einem älteren Build oder einem anderen Werkzeug beschädigtes Dokument liest sich sauber zurück, und das Zurückzuweisen des Records repariert es weder, noch verschlimmert es es. Um solche Dateien zu finden, brauchen Sie einen Byte-Level-Check, und genau dafür sind TPdf.ValidatePdfA und der PDF/A-Preflight-Validierungs-Workflow mit PDFium Component da

Wie löschen Sie eine Appearance absichtlich?

Sie setzen den Sentinel explizit und übergeben einen leeren String; der Setter schreibt ihn. Leere Strings in SetAnnotationData zu blockieren wäre die grobe Lösung für diesen Bug gewesen, hätte aber auch Aufrufer gebrochen, die eine Appearance gezielt leeren – derselbe Vertrag, dem HasContents und HasAuthor für Text folgen. Also wohnt der Fix komplett im Getter, und der Setter ehrt weiterhin, was der Aufrufer verlangt:

// Die Rollover-Appearance ersetzen, dann wieder leeren
A := Pdf.Annotation[0];
A.HasAppearanceRollover := True;
A.AppearanceRollover := 'q Q';
Pdf.Annotation[0] := A;

A := Pdf.Annotation[0];
// A.HasAppearanceRollover ist True und der Text läuft als 'q Q' durch
A.HasAppearanceRollover := True;   // Absicht explizit erneut behaupten
A.AppearanceRollover := '';        // absichtlich einen leeren Stream schreiben
Pdf.Annotation[0] := A;

A := Pdf.Annotation[0];
// Liest als HasAppearanceRollover = False mit leerem String zurück:
// ein leerer Stream und ein fehlender sind hier ununterscheidbar

Denken Sie daran: Ein explizit geleertes /R oder /D zählt unter den oben zitierten PDF/A-Regeln weiterhin als zusätzlicher Schlüssel. Wenn das Ziel ein Archivprofil ist, ist das Schreiben eines nicht-leeren /N bei unangetasteten anderen beiden Modi die einzige Form, die validiert. Jeder Workflow, der Annotationen zwischen Dokumenten bewegt, wie XFDF-Export und -Import mit PDFium Component, sollte derselben Regel folgen: Kopieren Sie die Modi, die die Quelle tatsächlich hatte, und lassen Sie den Rest der Sentinels auf False

Ein Read-Modify-Write-Muster, das PDF/A-sicher bleibt

Upgraden Sie auf v3.121.1 oder später, lassen Sie die Appearance-Sentinels exakt so, wie der Getter sie zurückgeliefert hat, und validieren Sie die gespeicherte Datei, bevor Sie sie ausliefern. Weil ein veralteter leerer Stream sich als abwesend zurückliest, muss der Verifikationsschritt ins serialisierte Dokument schauen statt in den Record, und er ist billig genug, um nach jedem Batch zu laufen:

uses
  PDFium, FPdfPdfa;  // FPdfPdfa deklariert TPdfAValidationIssue

function AnnotationAppearancesAreClean(Pdf: TPdf): Boolean;
var
  Report: TPdfAValidationResult;
begin
  // Validiert das in Pdf aktuell geladene Dokument, einschließlich
  // über Pdf.Annotation[] gemachter Änderungen seit dem Öffnen
  Report := Pdf.ValidatePdfA;
  Result := not (pvaiAnnotationApDictViolation in Report.Issues);
end;

Dieselbe Disziplin gilt für jedes Panel, das Seiten für ein Review umfärbt oder annotiert – ein Workflow, der in Aufbau eines Delphi-Annotation-Review-Workflows mit PDFium Component behandelt wird: Der Record ist ein Schnappschuss dessen, was die Engine lesen konnte, und ein Sentinel, den Sie nicht selbst gesetzt haben, sollte unverändert zurückreisen. Die vollständige Annotation-API, PDF/A-Preflight und die native PDFium-Engine kommen zusammen in PDFium Component for Delphi, C++Builder and Lazarus