Technischer Artikel

PDF-Annotationen ohne /AP-Stream in Delphi flatten

HotPDF v2.743.0 flacht PDF-Annotationen ohne /AP-Appearance-Stream ab, statt sie still zu überspringen. FlattenLoadedAnnotations führt ein Widget ohne Appearance nun über EnsureLoadedFieldAppearanceStream und erzeugt für Markup ohne Appearance ein Form XObject aus den eigenen Eigenschaften der Annotation. Dadurch bleiben die in ein /NeedAppearances-Formular eingegebenen Werte im Seiteninhalt erhalten, statt beim Flattening zu verschwinden. Der Fehler, der diese Änderung erzwang, sieht wie ein No-op aus. Ein Kunde schickt ein ausgefülltes Antragsformular, das aus einem Browser als PDF gedruckt wurde. Sie laden es in HotPDF, rufen FlattenLoadedAnnotations auf, erhalten 0, speichern und liefern ein Dokument mit leeren Feldern aus, wo der Antragsteller Namen und Betrag eingetragen hatte. Nichts löste einen Fehler aus, nichts wurde protokolliert. Die Werte lagen die ganze Zeit in der Datei, in jedem Feld im /V-Eintrag, und der Flattening-Durchlauf ging an ihnen vorbei, weil keines dieser Widgets einen Appearance-Stream trug

Warum gehen beim Flattening eines aus dem Browser gedruckten Formulars die Eingabewerte verloren?

Weil ein /NeedAppearances-Formular den Wert speichert, ohne ein Bild dieses Werts zu speichern. ISO 32000-1 12.7.2 erlaubt es einem interaktiven Formular, im AcroForm-Wörterbuch /NeedAppearances true zu setzen. Das weist den Viewer an, beim Öffnen aus /V, /DA und /Q die sichtbare Oberfläche jedes Felds zu erzeugen. Producer, die Formulare günstig erzeugen – Browser-Druckpfade, serverseitige Filler und manche Scan-Frontends –, nutzen dieses Angebot und schreiben überhaupt kein /AP. Flattening nach dem Appearance-Algorithmus aus ISO 32000-1 12.5.5 ist eine Übertragungsaufgabe: Den normalen Appearance-Stream der Annotation nehmen, seine /BBox auf ihr /Rect abbilden, ihn mit einem Do-Operator aus dem Seiten-Content-Stream aufrufen und anschließend die Annotation löschen. Ohne Quellstream gibt es nichts zu übertragen. Die ursprüngliche HotPDF-Implementierung aus v2.386.0 behandelte das als "skip", was isoliert vertretbar und in der Summe verheerend ist: Gerade die Dokumente, die Flattening am dringendsten benötigen, enthalten am seltensten Appearances. Dieselbe Lücke verschluckte Markup – ein Highlight aus einem Review-Tool, ein Square aus einer Redline-Runde oder eine Ink-Signatur –, wenn der Producer darauf vertraute, dass der Viewer es zeichnet

Wo FlattenLoadedAnnotations die Synthese einhängt

Der Einhängepunkt liegt absichtlich spät: nachdem die Appearance-Suche gescheitert ist, nicht davor. FlattenLoadedAnnotations fragt weiterhin zuerst mit GetLoadedAnnotationAppearanceStream nach der normalen Appearance, und eine Annotation, die bereits eine besitzt, wird genau wie in v2.386.0 eingebrannt. Nur ein nil-Ergebnis bei einer Annotation mit nicht degeneriertem /Rect und ohne Hidden-Flag gelangt in den Synthesepfad. Diese Reihenfolge ist wichtig: Ein Dokumentautor, der sich die Mühe gemacht hat, ein /AP zu schreiben, erhält seine eigenen Bytes zurück und keine HotPDF-Rekonstruktion davon

NStrm:= GetLoadedAnnotationAppearanceStream(Indices[PgI], AnI, aakNormal);
if (NStrm= nil) and (RR> RL) and (RT> RB) and ((FlagsValue and 2)= 0) then
begin
  if Subtype= 'Widget' then
  begin
    FieldIdx:= GetLoadedFormFieldIndexForAnnotation(Indices[PgI], AnI, WidgetIdx);
    if FieldIdx>= 0 then
      EnsureLoadedFieldAppearanceStream(FieldIdx);
    // erneut fragen: Der Generator hat /AP /N am Widget befestigt
    NStrm:= GetLoadedAnnotationAppearanceStream(Indices[PgI], AnI, aakNormal);
  end
  else
    NStrm:= SynthesizeMarkupAppearance(AnnotDict, Subtype, RL, RB, RR, RT);
end;

Von dort teilen sich die beiden Annotationfamilien. Ein Widget wird über GetLoadedFormFieldIndexForAnnotation auf sein Besitzerfeld zurückgeführt und an EnsureLoadedFieldAppearanceStream übergeben, den Feld-Appearance-Generator, den diese Delphi-PDF-Bibliothek seit v2.328.0 besitzt. Ihn wiederzuverwenden, statt einen zweiten Feldrenderer zu schreiben, ist der ganze Punkt. Er deckt bereits Type0-Fonts, Zeilenumbruch, Quadding, Checkbox- und Radio-/AS-Zustände sowie /MK-Rotation ab, also dieselbe Mechanik wie beim Hinzufügen von AcroForm-Feldern zu einem bereits geladenen PDF. Alles andere geht an den Markup-Synthesizer. Für den Aufrufer ändert sich nichts: Derselbe einzeilige Flatten-Aufruf liefert nun auf Dokumenten, die zuvor null meldeten, eine Zahl ungleich null

Doc:= THotPDF.Create(nil);
try
  Doc.LoadFromFile('needappearances-form.pdf');
  // v2.743.0: Widgets und Markup ohne AP werden synthetisiert und eingebrannt
  Flattened:= Doc.FlattenLoadedAnnotations;          // alle Seiten, alle Subtypen
  // Flattened:= Doc.FlattenLoadedAnnotations('1-3', 'Highlight');
  if Flattened= 0 then
    raise Exception.Create('nothing was flattened');
  Doc.SaveLoadedDocument('flattened.pdf');
finally
  Doc.Free;
end;

Warum landen QuadPoints und InkList an der falschen Stelle?

Weil diese Koordinaten im User Space der Seite liegen, während der synthetisierte Appearance-Stream in seinem eigenen /BBox-Space zeichnet, und die beiden Ursprünge nicht derselbe Punkt sind. ISO 32000-1 Tabelle 176 definiert /QuadPoints für Text-Markup-Annotationen im Default User Space, und Tabelle 174 tut dasselbe für die Endpunkte /L der Line-Annotation; /InkList folgt derselben Konvention. HotPDF gibt dem synthetisierten Form eine /BBox von [0 0 W H], deren Ursprung in der unteren linken Ecke von /Rect liegt. Jeder aus /QuadPoints, /L oder /InkList entnommene Punkt muss daher vor dem Schreiben in den Content-Stream um die negierte untere linke Ecke von /Rect verschoben werden. Macht man das falsch, zeichnet ein Highlight auf einer 700 Punkte weiter oben liegenden Zeile 700 Punkte über seine eigene Box und damit praktisch nirgends. Die Korrektur ist eine Subtraktion je Koordinate und verbindet sich mit dem anschließend vom Bake ausgegebenen cm: Diese Matrix bildet die /BBox zurück auf /Rect, sodass sich die beiden Schritte zu korrekter absoluter Geometrie aufheben

// /L-Endpunkte liegen im Seiten-User-Space (ISO 32000-1 Tabelle 174);
// der BBox-Ursprung liegt an der unteren linken /Rect-Ecke, daher um -(RL, RB) verschieben
X1:= ArrNum(LA, 0, 0)- RL;
Y1:= ArrNum(LA, 1, 0)- RB;
X2:= ArrNum(LA, 2, 0)- RL;
Y2:= ArrNum(LA, 3, 0)- RB;
StrokeOp:= ColorOp(DArr('C'), true);
if StrokeOp= '' then
  StrokeOp:= '0 G';
Result:= _FloatToStrR(BW)+ ' w '#10+ StrokeOp+ #10+
  _FloatToStrR(X1)+ ' '+ _FloatToStrR(Y1)+ ' m '+
  _FloatToStrR(X2)+ ' '+ _FloatToStrR(Y2)+ ' l S'#10;

Was die synthetisierte Markup-Appearance tatsächlich zeichnet

Der Markup-Synthesizer liest nur das Annotation-Wörterbuch. Das hält die Ausgabe vorhersehbar und macht ehrlich, was er nicht wissen kann. FreeText und Stamp zeichnen /Contents mit Font und Farbe aus /DA, ausgerichtet nach /Q und mit 2 pt Innenabstand. Square und Circle zeichnen eine mit /C gestrichene re-Kontur oder eine Bézier-Kontur aus vier Bögen und füllen sie, wenn vorhanden, mit /IC, bei der Breite aus /BS /W. Line und Ink zeichnen ihre Scheitelpunkte. Highlight füllt jedes Quad, während Underline, StrikeOut und Squiggly eine Linie am unteren Rand des Quads, in dessen Mitte oder als einpunktiges Zickzack zeichnen. Ein /CA unter 1 wird zu einem ExtGState mit einem ca-Eintrag, der am Anfang des Streams als /GSA gs referenziert wird

Die Textkodierung wird aus dem AcroForm-Eintrag /DR /Font bestimmt, den /DA benennt. Ist der /Subtype dieses Fonts Type0, schreibt HotPDF den String als UTF-16BE-Hexliteral mit dem Byte-Order-Mark FEFF. Andernfalls schreibt es einen maskierten Literalsstring, in dem Klammern und Backslashes maskiert und Bytes über 126 oktal ausgegeben werden. Der Tf-Operator aus /DA wird vor BT ausgegeben. Das ist legal, weil der Textzustand über die Grenze des Textobjekts hinweg erhalten bleibt, und erspart es, den /DA-String auseinanderzunehmen. Zwei Grenzen sollte man klar nennen. Die Zeilenbreite für Umbruch und Quadding wird mit einer Halb-Em-/Voll-Em-Heuristik statt über echte Fontmetriken geschätzt, daher ist die Ausrichtung auf einem Proportionalfont nah, aber nicht exakt. Und ein Subtyp ohne synthetisierbaren Inhalt – Popup, Link oder ein Stamp, dessen einziger Inhalt ein Iconname ist – liefert nil und bleibt genau wie bisher unverändert

Der temporäre /Annots-Tausch, den eine hilfreiche Bereinigung bestraft

FlattenOneWidget, der Pfad pro Widget aus FlattenLoadedFormFields, ist eine Aliasing-Falle, die jede Änderung innerhalb der gemeinsamen Flatten-Schleife beachten muss. Die Methode ersetzt den /Annots-Wert der Seite vorübergehend durch ein Array mit einem Element, damit der generische Flatten-Durchlauf auf einem einzelnen Widget arbeitet, und stellt anschließend den ursprünglichen PHPDFDictionaryItem-Zeiger in einem finally-Block wieder her. Die Wiederherstellung schreibt in einen Dictionary-Slot zurück, den sie vor dem Aufruf eingefangen hat

DictItem:= PHPDFDictionaryItem(PageObj.Items.Items[AnnotsIndex]);
Item:= DictItem^.Value;
TemporaryAnnots:= THPDFArrayObject.Create(nil);
TemporaryAnnots.AddObject(Target);
DictItem^.Value:= TemporaryAnnots;
try
  Result:= FlattenLoadedAnnotations(IntToStr(PageIndex+ 1), 'Widget')= 1;
finally
  DictItem^.Value:= Item;   // ungültig, wenn die innere Schleife dieses Element freigegeben hat
  TemporaryAnnots.Free;
end;

Fügt man innerhalb der gemeinsamen inneren Schleife eine vernünftig aussehende Bereinigung hinzu – etwa DeleteValue('Annots'), sobald das Array leer ist, damit die gespeicherte Seite kein leeres Restarray mitführt –, gibt dieser Aufruf genau das Dictionary-Element frei, auf das DictItem zeigt. Das finally schreibt dann über einen dangling pointer, und der Prozess stirbt mit "Invalid pointer operation". Zwei bestehende Tests fingen das sofort ab, und nur deshalb ist es eine Fußnote und kein Supportticket. Die allgemeine Regel lautet: Vor einer Bereinigung in einer gemeinsam genutzten Schleife die Aufrufer auf Alias- oder Tauschverträge prüfen. Ein leeres /Annots-Array ist ein kosmetischer Makel und keinen Handel gegen eine garantierte Pointerlebensdauer wert

Was unbaked bleibt und was Flattening kostet

Verborgene Annotationen werden absichtlich ausgeschlossen. Eine Annotation, deren /F-Integer das Bit an Position 2 gesetzt hat, ist nach ISO 32000-1 12.5.3 verborgen. Wenn sie außerdem kein /AP besitzt, ist die Versuchung groß, eine Appearance zu synthetisieren und sie wie den Rest einzubrennen. Das wäre ein Fehler mit Sicherheitsfolgen: Eine unsichtbare Notiz in den Seiteninhalt einzubrennen, macht sie für jeden sichtbar, der die Datei öffnet. HotPDF lässt solche Annotationen genau dort und zählt sie nicht im Rückgabewert. Ebenso klar muss man Nutzern den Preis der eingebrannten Annotationen erklären. Flattening ist unumkehrbar: Die Annotation wird aus dem /Annots-Array der Seite gelöscht und ihre sichtbare Darstellung ist nun Seiteninhalt. Damit gibt es kein Bearbeiten des Feldwerts mehr, keinen Kommentar-Thread, kein Umschalten des /AS-Zustands und keine Möglichkeit, die strukturierten Daten außerhalb der Originaldatei wiederzugewinnen. Flattenen Sie eine Kopie, bewahren Sie das Original und greifen Sie darauf nur dort zurück, wo das Dokument aufhört, ein Formular zu sein, und zu einem Datensatz wird. Bei einem XFA-gestützten Problem statt einem Problem ohne Appearance ist der separate XFA-zu-AcroForm-Flattening-Weg in HotPDF der richtige Startpunkt; wenn Sie das Formular noch bauen, behandeln die Notizen zum Verdrahten von AcroForm-Feldaktionen und Validierung die Schreibseite

Eine Prüfungseinschränkung spart Ihnen sonst einen Nachmittag. ExtractLoadedPageGlyphs steigt nicht in Form XObjects hinab, und eine eingebrannte Appearance liegt innerhalb eines solchen Objekts. Der Seiten-Content-Stream enthält nur eine Sequenz q ... cm /FlatAn<n> Do Q. Die Glyph-Extraktion auf einer geflatteten Seite meldet daher nichts, und das ist korrektes Verhalten und keine verlorene Appearance. Prüfen Sie entweder auf Byteebene – anhand des Ressourcennamens /FlatAn, des Do-Aufrufs und /Subtype /Form – oder über die Rendering-Pipeline, die XObjects erweitert

Annotation-Flattening sieht so lange wie eine dreizeilige Übertragung aus, bis man die Dokumente trifft, die Menschen tatsächlich erzeugen. Wenn Sie mit ausgefüllten Formularen, Review-Markup oder Archiv-Ausgaben in Delphi oder C++Builder arbeiten, lohnt es sich, vor dem Bau eines eigenen Appearance-Generators zu lesen, wie die HotPDF Delphi PDF component die Seite geladener AcroForms und Annotationen behandelt