Technischer Artikel

PDF-Annotationen in Delphi mit PDFium Component prüfen

Eine PDF-Annotation ist ein Dictionary, das an eine Seite angehängt ist, keine auf ihr gezeichnete Markierung. ISO 32000-1 §12.5 definiert rund zwei Dutzend Subtypen, und jeder trägt einen /Subtype, ein Rechteck in Seitenkoordinaten, einen Satz Flags und meist einen Appearance-Stream, der entscheidet, was ein Betrachter tatsächlich zeichnet. Für eine Person, die ein Dokument prüft, bedeuten die Subtypen nicht alle dasselbe. Ein Highlight und ein Ink-Strich sind Kommentare; ein Link ist Navigation; ein Popup ist das kleine Fenster, das sich öffnet, wenn Sie auf eine Haftnotiz klicken, gespeichert als eigenes Objekt, auf das ein Elternobjekt zeigt. Antworten sind vollwertige Text-Annotationen, die über einen In-Reply-To-Eintrag auf den Kommentar verweisen, den sie beantworten. Das Annotations-Array auf Seitenebene ist also nicht die Kommentarliste des Reviewers. Es ist ein flacher Sack mit Kommentaren, der Verdrahtung, die sie verbindet, und einigen Dingen, die kein Reviewer je als Kommentar bezeichnen würde. Ein Panel, das dieses Array als Kommentarliste behandelt, wird jedem anderen Betrachter widersprechen, den der Kunde einsetzt

Einen Annotations-Review-Workflow auf der PDFium Component aufzubauen, der PDFium-basierten VCL/LCL-Komponente für Delphi, C++Builder und Lazarus, heißt, sich auf die Stellen zu konzentrieren, an denen diese Lücke zwischen dem rohen Array und der menschlichen Sicht Ärger macht: Zählen, Indexieren, das Umfärben von Markierungen, die die Engine bereits eingefroren hat, das Löschen ohne Geisterbilder und das Hinzufügen eigener Markierungen

Diagramm, das zeigt, wie ein Delphi-PDFium-Review-Panel das rohe Annotations-Array der Seite aus Kommentaren, Popups, Antworten und Links zur kuratierten Kommentarliste filtert, die der Reviewer sieht
Das Annotations-Array der Seite mischt Kommentare mit Popups, Antworten, Links und verborgenen Markierungen, daher braucht ein Review-Panel eine Zählregel, bevor es eine Summe anzeigt

Warum Ihre Zählung nie mit dem Kommentarfenster von Acrobat übereinstimmt

Öffnen Sie einen mit Anmerkungen versehenen Vertrag nebeneinander in Ihrem Betrachter und in Acrobat, und die Summen stimmen selten überein. Acrobat zeigt eine kuratierte Sicht: Markup zu Antwort-Threads gruppiert, Popups in die Notizen eingefaltet, zu denen sie gehören, Links und Formular-Widgets ausgelassen. Das rohe Array enthält all das undifferenziert, sodass eine naive Zählung gleichzeitig in mancher Hinsicht zu hoch und in anderer zu niedrig ausfällt

Popups blähen die Summe auf, denn jede Haftnotiz bringt ein separates Popup-Objekt mit, und wer beide zählt, zählt die Notiz doppelt. Antworten drücken sie nach unten, wenn Sie nach sichtbaren Markierungen filtern, denn eine Antwort ist eine Text-Annotation, bei der nichts gezeichnet wird, bis jemand den Thread aufklappt, und wer sie verwirft, verliert die Diskussion. Die Flags Hidden und NoView nehmen eine Annotation vom Bildschirm, ohne sie aus dem Array zu nehmen, sodass eine Flag-blinde Zählung Markierungen einschließt, die der Benutzer nicht sehen kann. Link-Annotationen liegen im selben Array wie die Kommentare und gehören weder in die Zählung noch in die Liste. Legen Sie die Zählregel fest, bevor Sie die Schleife schreiben, und halten Sie die Entscheidung schriftlich fest, denn „warum zeigt Ihr Panel eine andere Zahl als Acrobat“ ist das erste Ticket, das sich ein Review-Feature einhandelt

Alles einmal indexieren und nie wieder eine Seite neu parsen

Eine Designregel treibt alles Folgende an: Das Filtern nach Autor, Typ oder Seite darf niemals Seitenobjekte neu parsen. Bei einem 300-seitigen Dokument mit viel Markup verwandelt das Neuparsen bei jeder Dropdown-Änderung das Panel in etwas, das sekundenlang stottert. Die Komponente stellt AnnotationCount und die indizierte Eigenschaft Annotation[] bereit, beide auf die aktuell geladene Seite bezogen, und der zurückgegebene TPdfAnnotation-Record trägt, was eine Listenansicht braucht: Subtype, Flags, Color, Rectangle, ContentsText, AuthorText. Der richtige Schritt ist, beim Öffnen jede Seite einmal zu durchlaufen und einen eigenen flachen Index zu führen:

procedure TReviewPanel.BuildIndex;
var
  PageNo, i: Integer;
  A: TPdfAnnotation;
begin
  FItems.Clear;
  for PageNo := 1 to Pdf.PageCount do
  begin
    Pdf.PageNumber := PageNo;
    for i := 0 to Pdf.AnnotationCount - 1 do
    begin
      A := Pdf.Annotation[i];
      // Nur für Reviewer relevante Subtypen behalten; Seite und Index
      // als Paar merken, weil alle späteren Änderungen darüber adressiert werden
      if A.Subtype in [anText, anHighlight, anInk] then
        FItems.Add(TReviewItem.Create(PageNo, i,
          A.AuthorText, A.ContentsText, A.Rectangle, A.Color));
    end;
  end;
end;

Das Paar, das es zu unterstreichen gilt, ist (PageNo, i). Jede spätere Änderung, ob Umfärben oder Löschen, wird über Seitennummer plus Annotationsindex adressiert, und der Index ist fragil: Das Entfernen einer Annotation nummeriert alles danach auf dieser Seite neu. Planen Sie also, die Einträge der betroffenen Seite nach jeder Löschung neu aufzubauen, statt Indexnummern an Ort und Stelle zu flicken. Der Neuaufbau kostet eine Millisekunde. Ein veralteter Index dagegen löscht den Kommentar des falschen Reviewers, und das ist die Art von Fehler, die das Vertrauen in das ganze Feature untergräbt

Threading verdient einen Platz im Index, selbst wenn Ihr erstes Release Antworten nur zählt, statt sie anzuzeigen. Gruppieren Sie die Einträge nach ihrer Elternreferenz, solange Sie die Seite geöffnet haben, damit das Panel später einen Thread so einfalten kann, wie Acrobat es tut. Diese Gruppierung erst beim Scrollen verzögert zu rekonstruieren, macht den ganzen Sinn des einmaligen Indexierens zunichte, weil es Seiten erneut öffnet, deren Parsen Sie bereits bezahlt haben. Geometrie verlangt dieselbe Disziplin. Das Rectangle in jedem Record liegt im Seitenraum, und seine Umrechnung in Ansichtskoordinaten gehört in einen gemeinsamen Helfer, nicht verstreut über den Code. Panels züchten Koordinatenfehler, wenn Auswahl, Treffertest und Zeichnen jeweils ihre eigene Zoom- und Rotationsmathematik erfinden; leiten Sie alle drei durch eine einzige Umrechnung, und eine Hervorhebung, ihre Zeile in der Liste und ihr Klickziel bleiben an derselben Tinte verankert

Markup umfärben und das Veto des Appearance-Streams

Eine Hervorhebung von Gelb auf Bernstein umzustellen klingt nach einem Einzeiler, und manchmal ist es einer. Der Haken ist ISO 32000-1 §12.5.5. Trägt eine Annotation einen /AP-Appearance-Stream, zeichnet ein konformer Betrachter diesen vorgefertigten Stream und behandelt den Farbeintrag im Dictionary als tote Metadaten. Acrobat schreibt Appearance-Streams für praktisch alles, was es erzeugt, sodass die meisten von Kunden eintreffenden Annotationen bereits in diesem Zustand sind und die Farbe, die Sie so selbstbewusst setzen, den Bildschirm nie erreicht. Umfärben ist ein Read-Modify-Write über die Eigenschaft Annotation[], und die Komponente ist ehrlich, was den Konflikt angeht: Wenn die Engine sich weigert, eine Dictionary-Farbe ein eingebackenes Erscheinungsbild überschreiben zu lassen, löst der Schreibzugriff EPdfError aus

Diagramm des Read-Modify-Write-Umfärbepfads in einer Delphi-PDFium-Komponente, in dem ein eingebackener Appearance-Stream die Dictionary-Farbe per Veto verwirft und EPdfError auslöst
Trägt eine Annotation einen vorgefertigten /AP-Stream, verweigert die Engine die Dictionary-Farbe und löst EPdfError aus, daher färbt das Panel sein eigenes Overlay um oder markiert die Zeile als appearance-locked
A := Pdf.Annotation[Item.Index];
A.HasColor := True;
A.Color := $0000B0FF;       // Bernstein
A.ColorAlpha := 160;
try
  Pdf.Annotation[Item.Index] := A;
except
  on EPdfError do
  begin
    // Die Annotation besitzt einen vorgerenderten /AP-Stream; die
    // Dictionary-Farbe allein ändert nicht, was Betrachter zeichnen
    Item.AppearanceLocked := True;
    StatusBar.SimpleText := 'Color is fixed by the annotation appearance';
  end;
end;

Fangen Sie diese Exception jedes Mal ab und behandeln Sie sie als Information statt als Fehlschlag. Lassen Sie die Absicherung weg, zeigt Ihr Panel fröhlich Bernstein in seiner eigenen Liste, während die Seite weiter Gelb zeichnet; der Benutzer meldet es Wochen später als „Ihr Betrachter ignoriert meine Änderungen“, und Sie verbringen einen Nachmittag damit, es an einer Datei, die zufällig keinen Appearance-Stream hat, nicht reproduzieren zu können. Sobald Sie wissen, dass das Erscheinungsbild gesperrt ist, haben Sie zwei ehrliche Antworten: Färben Sie Ihr eigenes Auswahl-Overlay statt der Annotation um, damit der Reviewer zumindest die gewählte Hervorhebung sieht, oder markieren Sie die Zeile als appearance-locked, damit niemand erwartet, dass die Änderung bleibt

Annotationen löschen, ohne Geisterbilder zu hinterlassen

DeleteAnnotation entfernt das Objekt aus dem Annotationsbaum der aktuellen Seite, lässt das zwischengespeicherte Seitenraster aber unangetastet. Zeichnen Sie direkt nach dem Aufruf, ist die gelöschte Hervorhebung noch auf dem Bildschirm, in einer Bitmap, die nicht mehr zum dahinterliegenden Dokumentmodell passt. Die Abhilfe ist, das Neurendern als Teil des Löschens zu behandeln, nicht als Schritt, den der Aufrufer vergessen könnte:

Diagramm des dreistufigen Delphi-PDFium-Löschzyklus, der die Annotation entfernt, die Seite mit reAnnotations neu rendert und den Seitenindex neu aufbaut
Löschen berührt nur den Annotationsbaum, daher muss das Panel mit reAnnotations neu rendern und die Seiteneinträge neu aufbauen, bevor Anzeige und Index wieder ehrlich sind
Pdf.PageNumber := Item.PageNo;
Pdf.DeleteAnnotation(Item.Index);   // löst bei Fehler EPdfError aus
Bmp := Pdf.RenderPage(0, 0, ViewWidth, ViewHeight, ro0, [reAnnotations]);
try
  PaintPageBitmap(Bmp);
finally
  Bmp.Free;  // RenderPage übergibt den Bitmap-Besitz an den Aufrufer
end;
RebuildPageEntries(Item.PageNo);  // Indizes nach Item.Index haben sich verschoben

Zwei Details in diesem Block geraten leicht falsch. Die Option reAnnotations muss vorhanden sein, sonst lässt das neue Raster jede verbliebene Annotation fallen, und die Seite sieht aus, als hätten Sie den gesamten Kommentarsatz statt einer einzigen Markierung gelöscht. Und das Bmp.Free ist nicht optional: Die funktionsartige RenderPage-Überladung übergibt den Bitmap-Besitz an den Aufrufer, sodass ein fehlendes Free bei jedem einzelnen Löschvorgang ein ganzseitiges Raster leckt, was ein Reviewer, der sich durch ein langes Dokument arbeitet, binnen Minuten in echten Speicherdruck verwandelt

Reviewer-Markierungen aus der eigenen UI hinzufügen

Das Erzeugen von Annotationen läuft über CreateAnnotation, das einen ausgefüllten TPdfAnnotation-Record (Subtyp, Rechteck, Farbe, Inhalt, Autor) entgegennimmt und ihn an die aktuelle Seite anhängt. Eine Haftnotiz, Subtyp anText, ist der einfache Fall: Position, Inhalt und Autor setzen, fertig. Bei Ink-Annotationen tappen die Leute in die Falle. Das Rechteck des Records begrenzt nur die Zeichnung; die Striche selbst sind Punkt-Arrays, die separat über den Ink-Stroke-Aufruf der Engine angehängt werden müssen, FPDFAnnot_AddInkStroke mit FS_POINTF-Daten gefüttert, die Strich für Strich aus Maus- oder Stifteingabe erfasst werden. Bauen Sie eine Ink-Annotation aus einem Rechteck und sonst nichts, erhalten Sie ein leeres Gekritzel, das als Leerraum gerendert wird, was wie ein Fehler in der Engine aussieht und in Wirklichkeit eine halbfertige Annotation ist

Klären Sie im selben Atemzug die Autorenrichtlinie. Jede Markierung, die Ihre UI erzeugt, sollte einen konsistenten AuthorText tragen, denn der Reviewer-Filter, den Sie nächsten Monat bauen, ist nur so gut wie die Namen, die Sie heute auf Kommentare stempeln. Leere oder inkonsistente Autoren-Strings lassen sich nachträglich nicht reparieren, ohne jede Datei erneut zu öffnen

Das Review aus dem Betrachter herausbekommen

Review-Daten verdienen ihren Unterhalt, sobald sie den Betrachter verlassen können, als Zusammenfassung, die der Projektleiter liest, ohne die Datei zu öffnen, oder als CSV, die ein Tracking-Sheet speist. Exportieren Sie aus dem Index, den Sie bereits gebaut haben, nie aus einem frischen Parse, und wählen Sie eine stabile Art, auf jede Markierung zurückzuverweisen. Eine Seitennummer gepaart mit dem Rechteck der Annotation übersteht Round-Trips, die ein Array-Index nicht übersteht, denn die nächste Löschung nummeriert die Indizes stillschweigend neu, und Ihre CSV zeigt plötzlich auf die falschen Kommentare

Eine Zeile, die sich zu behalten lohnt, trägt die Seite, den Subtyp, den Autor, den Erstellungszeitstempel, wenn die Datei einen festhält, den Inhaltstext und eine Statusspalte, die Ihnen gehört statt eine, die die PDF liefert. Derselbe Indexierungsdurchlauf ist schon früher nützlich, beim Eingang, wenn ein Dokument von außerhalb des Teams eintrifft und Sie wissen wollen, was darin steckt, bevor jemand es prüft. Der Artikel zur PDF-Eingangs-Workbench geht diese Triage durch, und die Formularfeld-Navigation behandelt das spiegelbildliche Problem: das Prüfen von Dokumenten, die zum Sammeln von Daten statt von Kommentaren gebaut wurden

Ein Fall, den das Array Ihnen nicht zeigt

Ein Fehlermodus verdient einen Hinweis, weil er wie ein Defekt in Ihrem Code aussieht und keiner ist. Ein Kunde meldet sichtbare Hervorhebungen überall auf einer Seite, aber Ihr Panel listet nichts, und AnnotationCount liefert null zurück. Die übliche Erklärung ist, dass die Markierungen irgendwo vorgelagert flachgerechnet wurden. Flattening backt die Erscheinungsbilder der Annotationen in gewöhnlichen Seiteninhalt ein, sodass die Hervorhebungen Teil der Seitengrafik werden und als Annotationsobjekte vollständig aufhören zu existieren. Es bleibt nichts übrig, was eine Annotations-API aufzählen, umfärben oder löschen könnte. Sehen Sie gezeichnetes Markup bei einer Zählung von null, hören Sie auf, den Fehler in Ihrer Aufzählungsschleife zu suchen, und fragen Sie, wie die Datei erzeugt wurde

Die hier verwendete Annotations-Oberfläche, von Aufzählung und Erzeugung über Umfärben und Löschen bis zu den Render-Optionen, die die Anzeige ehrlich halten, wird mit der PDFium Component für Delphi, C++Builder und Lazarus/FPC ausgeliefert