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
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
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 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