Technischer Artikel

FDF-Annotationen in Delphi: Der stille Null-Zähler im Import

Vor v3.539.30 gab TPDFlib.ImportAnnotationsFromFDFString in der losLab PDF Library die Anzahl der geparsten FDF-Annotation-Einträge zurück, ohne einen einzigen davon ins Dokument zu übernehmen: Jeder Eintrag wurde gezählt, jeder Eintrag wurde verworfen. Seit v3.539.30 liest der FDF-Importer Schlüssel in beliebiger Reihenfolge, parst /Rect korrekt und locale-unabhängig, und der zugehörige Exporter schreibt das echte /Rect der Annotation, sodass Export, Import und zweiter Export byte-identisches FDF ergeben. Der Rest dieser Notiz erklärt, wie ein einziger falscher Start-Offset einen perfekten stillen Ausfall produzierte, welche drei weiteren Defekte sich dahinter versteckten, und wie Sie einen Import selbst prüfen, statt dem Rückgabewert zu vertrauen

Die Situation ist alltäglich. Ein Reviewer markiert einen Vertrag, die Kommentare reisen als FDF-Datei (Acrobat nennt das Export Comments), und Ihr Delphi-Dienst führt sie per ImportAnnotationsFromFDF mit einer sauberen Kopie zusammen. Der Aufruf liefert 7, das Log sagt „7 Kommentare importiert“, der Job wird grün, und die Ausgabe-PDF enthält keinerlei Kommentare. Nichts wurde geworfen, nichts wurde gewarnt, und die Zahl sah plausibel aus, weil sie die echte Anzahl der Einträge in der Datei war. Das ist die schlimmste Form, die ein Bug annehmen kann: eine Funktion, deren einziges Erfolgssignal ein Zähler ist, der unabhängig von der Arbeit berechnet wird, die er angeblich berichtet

Warum meldete ImportAnnotationsFromFDFString Erfolg, fügte aber nichts hinzu?

Der Importer las jedes /Subtype als leeren String, und der Helfer, der die Annotation erzeugt, steigt bei leerem Subtyp aus, während der Aufrufer das Ergebnis trotzdem hochzählt. Der Schlüsselfinder gab die Position unmittelbar nach /Subtype zurück, also das Whitespace vor dem Wert. ReadName startete an diesem Leerzeichen und hielt am ersten Whitespace-Zeichen an, las also nichts ein. AddAnnotationToPage verweigert den Bau einer Annotation ohne Subtyp, was für sich genommen die richtige defensive Wahl ist, aber es war eine Prozedur ohne Rückgabewert, und Inc(Result) saß außerhalb. Jede einzelne Absicherung war vernünftig; zusammen machten sie aus „nichts funktioniert“ ein „alles funktioniert“. Der Fix bringt ReadName bei, Whitespace zu überspringen, das führende / eines PDF-Namensobjekts zu verlangen und an jedem Delimiter anzuhalten, inklusive [, ( und ), sodass /Subtype/Text und /Subtype /Text beide Text liefern

PDFlibPas ImportAnnotationsFromFDFString fand /Subtype, startete ReadName auf dem Whitespace nach dem Schlüssel, sodass ein leerer Name zurückkam, AddAnnotationToPage stieg beim fehlenden Subtyp aus, und der Aufrufer zählte trotzdem hoch — sieben importierte Kommentare wurden gemeldet, während keiner ins Dokument kam
Jede Absicherung war für sich genommen vernünftig; zusammen machten sie aus nichts funktioniert ein alles funktioniert — deshalb darf der Rückgabewert nie das Einzige sein, was ein Importtest prüft

Der Rückgabewert brauchte auch nach diesem Fix Sorgfalt. Bis einschließlich v3.539.39 zählte ImportAnnotationsFromFDFString sein Ergebnis für jedes wohlgeformte Dictionary im /Annots-Array hoch, auch für Einträge, deren nullbasiertes /Page außerhalb des Bereichs lag oder deren /Subtype fehlte — beides wird übersprungen. Seit PDFlibPas v3.539.40 geben ImportAnnotationsFromFDFString und ImportAnnotationsFromFDF die Anzahl der tatsächlich hinzugefügten Annotationen zurück, wie der XFDF-Import: Der FDF-Helfer AddAnnotationToPage liefert jetzt ein Boolean, und der Zähler bewegt sich nur noch bei Erfolg. Das Ausmessen des Dokuments bleibt die stärkere Prüfung, weil sie auch auf älteren Versionen gilt, daher vergleicht die Skizze unten AnnotationCount auf jeder Seite vor und nach dem Import

function TotalAnnotations(Lib: TPDFlib): Integer;
var
  Page, Saved: Integer;
begin
  Result := 0;
  Saved := Lib.SelectedPage;
  for Page := 1 to Lib.PageCount do
    if Lib.SelectPage(Page) = 1 then
      Inc(Result, Lib.AnnotationCount);   // pro ausgewählter Seite, Widgets eingeschlossen
  Lib.SelectPage(Saved);
end;

var
  Lib: TPDFlib;
  Before, Reported, Added: Integer;
begin
  Lib := TPDFlib.Create;
  try
    Lib.LoadFromFile('contract.pdf', '');
    Before := TotalAnnotations(Lib);
    Reported := Lib.ImportAnnotationsFromFDF('review-comments.fdf');
    Added := TotalAnnotations(Lib) - Before;
    if Added <> Reported then   // gleich seit v3.539.40
      Writeln(Format('Importer reported %d, %d landed on a page', [Reported, Added]));
    Lib.SaveToFile('contract-reviewed.pdf');
  finally
    Lib.Free;
  end;
end;

Drei weitere Defekte hinter dem ersten

Den Subtyp allein zu reparieren, hätte drei weitere Bugs in derselben Funktion exponiert, die bis dahin nur deshalb unsichtbar waren, weil nie eine Annotation eine Seite erreichte. Erstens nahm ReadNumber seine Position als Wertparameter, das sequenzielle Lesen der vier /Rect-Zahlen las also viermal dieselbe Stelle, und das öffnende [ übersprang es nicht, in der Praxis las es also gar nichts. Zweitens teilte sich FindKey einen einzigen vorwärtswandernden Cursor über alle Lookups. Der Exporter schreibt /Subtype, /Rect, /Page, /Contents, /T, /Subj, aber der Importer suchte in der Reihenfolge /Subtype, /Contents, /T, /Subj, /Page, /Rect; war der Cursor einmal an /Contents vorbeigelaufen, lief die Suche nach /Page und /Rect über den aktuellen Eintrag hinaus und fand entweder nichts oder traf auf die Schlüssel der nächsten Annotation. Die Bibliothek konnte ihre eigene Ausgabe nicht lesen. Drittens liefen Zahlen durch PLStrToFloat, das dem systemweiten Dezimaltrennzeichen folgt. ISO 32000-1 §12.7.7 definiert FDF als PDF-Objektsyntax, und Dictionary-Schlüssel sind in PDF ungeordnet (§7.3.7), jeder FDF-Parser, der eine Schlüsselreihenfolge annimmt, ist also konstruktionsbedingt falsch, egal welches Tool die Datei erzeugt hat

Der reparierte Importer begrenzt zuerst jeden Eintrag. FindDictEnd läuft vom öffnenden << zum passenden >>, verfolgt verschachtelte Dictionaries und überspringt Literal-String-Körper samt Backslash-Escapes, sodass ein >> innerhalb eines Kommentars wie (see section >> 4) den Eintrag nicht vorzeitig beenden kann. Jeder Schlüssel-Lookup startet dann am eigenen Anfang des Eintrags und ist auf sein Ende begrenzt, was die Schlüsselreihenfolge irrelevant macht und verhindert, dass eine Annotation sich das /Page einer anderen ausleiht. Der Schlüsselvergleich akzeptiert auch einen Delimiter direkt hinter dem Namen, denn /Contents(Hi) ist genauso gültig wie /Contents (Hi), während die Wortgrenzenregel /Subj davon abhält, den Anfang von /Subtype zu treffen, und /T davon, /Type zu treffen. ReadNumber nimmt seine Position jetzt als var-Parameter, überspringt Whitespace und [ und parst mit PLTryStrToFloatInvariant, das bei einem missratenen Token weich scheitert, statt zu werfen. Scheitert eine der vier Rechteckzahlen, fallen alle vier auf null zurück, statt ein halb gelesenes Rechteck zu produzieren

PDFlibPas FindDictEnd begrenzt jetzt jede FDF-Annotation vom öffnenden << bis zum passenden >>, jeder Schlüssel-Lookup startet also am Eintragsanfang und stoppt an seinem Ende, und ReadNumber nimmt eine var-Position, überspringt die Klammer und parst mit PLTryStrToFloatInvariant
Der geteilte Cursor konnte die eigene Export-Ausgabe der Bibliothek nicht lesen: War er einmal an /Contents vorbeigelaufen, liefen die /Page- und /Rect-Suchen in die Schlüssel der nächsten Annotation — die Schlüsselreihenfolge darf also nicht länger eine Rolle spielen

Warum verschob der FDF-Roundtrip jede Annotation um ihre eigene Höhe?

Der alte Exporter schrieb ein Rechteck im falschen Koordinatenmodell. Das /Rect einer Annotation ist [llx lly urx ury] im Default User Space (ISO 32000-1 §12.5.2, Rechtecke definiert in §7.9.5), und FDF transportiert dasselbe Array. ExportAnnotationsToFDFString rief jedoch GetAnnotRectEx auf, das Left, Top, Width und Height in den Zeichenkoordinaten der Bibliothek meldet — dem Raum, den SetOrigin steuert — und serialisierte sie als [L T L+W T+H]. Der Importer schrieb, einmal funktionsfähig, diese vier Werte unverändert als PDF-Rechteck zurück, die obere Kante landete also dort, wo die untere linke Ecke hingehörte, und jeder Roundtrip verschob die Annotation um ihre eigene Höhe nach oben. Der Exporter kopiert jetzt die eigenen /Rect-Zahlen der Annotation — drei Dezimalstellen, Punkt als Trenner, kein Exponent — und greift nur zurück auf das berechnete Rechteck, wenn das gespeicherte Array fehlt oder keine vier Zahlen enthält

PDFlibPas serialisierte FDF /Rect früher als Left, Top, Width und Height in Zeichenkoordinaten, beim Zurückimport dieser vier Zahlen als llx lly urx ury landete die obere Kante dort, wo die untere linke Ecke hingehörte, und jeder Roundtrip verschob jede Annotation um ihre eigene Höhe nach oben
Der Exporter kopiert jetzt die eigenen /Rect-Zahlen der Annotation — drei Dezimalstellen, Punkt als Trenner, kein Exponent — und der Regressionstest vergleicht einen zweiten Export byte für byte mit dem ersten

Der Regressionstest, der das festnagelt, ist eine Kopie wert, denn er prüft am Dokument und an einem zweiten Export, nicht am Rückgabewert des Importers. Beachten Sie die erwartete Anzahl 2: AddNoteAnnotation erzeugt eine Text-Annotation samt Popup, und beide reisen mit. Der Test fährt Export und Import außerdem unter einem Komma als Dezimaltrennzeichen — genau dort lebt die andere Hälfte dieser Geschichte

var
  Source, Target: TPDFlib;
  FDF: AnsiString;
  OldSep: Char;
begin
  Source := TPDFlib.Create;
  Target := TPDFlib.Create;
  try
    Source.NewPages(1);                     // jetzt zwei Seiten
    Source.SelectPage(2);
    Source.AddNoteAnnotation(50.5, 60.25, 0, 80, 80, 120, 60,
      'Reviewer', 'Check this', 0.25, 0.5, 0.75, 0);
    Target.NewPages(1);

    OldSep := FormatSettings.DecimalSeparator;
    FormatSettings.DecimalSeparator := ',';   // deutschen oder französischen Desktop simulieren
    try
      FDF := Source.ExportAnnotationsToFDFString;   // schreibt weiterhin /Rect [50.5 ...
      Target.ImportAnnotationsFromFDFString(FDF);
    finally
      FormatSettings.DecimalSeparator := OldSep;
    end;

    Target.SelectPage(2);
    Assert(Target.AnnotationCount = 2);           // die Notiz und ihr Popup
    Assert(Target.GetAnnotType(1) = 'Text');
    Assert(Target.ExportAnnotationsToFDFString = Source.ExportAnnotationsToFDFString);
  finally
    Target.Free;
    Source.Free;
  end;
end;

Seien Sie klar darüber, was der FDF-Pfad transportiert. Der Importer baut jeden Eintrag als Dictionary mit /Type, /Subtype, /Rect, /Contents, /T und /Subj wieder auf; Farbe, Flags, Border-Stil, Popup-Links und Appearance-Streams sind auf dieser Route nicht dabei, und der Exporter überspringt Widget-Annotationen, weil Formfelder zu den Form-Data-Methoden gehören. Die breitere Landkarte, welche Daten durch welche Methode reisen, steht in der Übersicht zum FDF-, XFDF- und XFA-Formulardatenaustausch, und wenn Sie nachprüfen wollen, was tatsächlich angekommen ist, behandelt Outline-, Annotation- und Action-Introspektion die Lesezugriffe pro Index wie GetAnnotType, GetAnnotTitle und GetAnnotContentsEx

Wie lesen Sie FDF- und XFDF-Dateien mit Komma-Dezimalstellen aus älteren Exporten?

Bei FDF ist die Antwort eindeutig: Ein Komma ist in der PDF-Syntax kein Delimiter, ein Zahl-Token, das exakt ein Komma und keinen Punkt enthält, kann also nur eine Dezimalzahl von einer Komma-Locale-Maschine sein. Frühere Versionen haben solche Dateien tatsächlich geschrieben, etwa /Rect [10,500 20,250 40,750 60,125], und das neue ReadNumber verwandelt dieses eine Komma vor dem Parsen in einen Punkt. Ein Token mit zwei Kommas oder mit Komma und Punkt wird abgelehnt, statt geraten. Exponentialschreibweise konsumiert der Reader ebenfalls nicht, passend zu ISO 32000-1 §7.3.3: PDF-Zahlen benutzen sie nie

XFDF ist haariger, denn in XML-Attributen ist das Komma der Separator. Standard-XFDF (ISO 19444-1) schreibt rect="50.5,80.25,70.75,100.125" und dashes="4,2", während v3.539.28 und früher auf einem Komma-Locale-System rect="50,500 80,250 70,750 100,125" und opacity="0,600" schrieben und zudem mit EConvertError scheiterten, wenn sie ein standardmäßiges opacity="0.6" lasen. Seit v3.539.29 ist in beide Richtungen invariant, und die Legacy-Form erkennt XFDFNormalizeLegacyDecimals nur dann, wenn das Attribut an Whitespace in exakt der erwarteten Tokenzahl zerfällt (vier für rect, einer für opacity und width) und jedes Token die Form Ziffern-Komma-Ziffern hat. Ein standardmäßiges rect passt nie: Es ist entweder ein Token mit drei Kommas oder Tokens, die auf ein Komma enden. dashes bleibt bewusst unangetastet, denn 4,2 könnte zwei Strichlängen sein oder ein Legacy-4.2, und keine Regel kann das unterscheiden

const
  // Schlüssel außerhalb der Exporter-Reihenfolge, dazu Komma-Dezimalzahlen
  LegacyFDF: AnsiString = '%FDF-1.2'#10'1 0 obj'#10'<< /FDF << /Annots ['#10 +
    '<< /Rect [10,500 20,250 40,750 60,125] /Page 0 /Contents (First) ' +
    '/Subtype /Text /T (Alpha) /Type /Annot >>'#10 +
    '] >> >>'#10'endobj'#10'trailer'#10'<< /Root 1 0 R >>'#10'%%EOF'#10;
var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;               // ein frisches Dokument hat eine Seite
  try
    Lib.ImportAnnotationsFromFDFString(LegacyFDF);
    Assert(Lib.AnnotationCount = 1);
    Assert(Lib.GetAnnotTitle(1) = 'Alpha');
    // Re-exportiert als XFDF mit Punkt-Dezimalstellen: rect="10.500 20.250 40.750 60.125"
    Writeln(Lib.ExportAnnotationsToXFDFString);
  finally
    Lib.Free;
  end;
end;

Was sollte ein Annotation-Importtest wirklich prüfen?

Ein nützlicher Importtest prüft den Zustand des Zieldokuments, niemals nur, was der Importer über sich selbst erzählt. Nichts in der Testsuite hatte nach einem FDF-Import AnnotationCount kontrolliert, und der Rückgabewert, die einzige Zahl, die sich jemand ansah, war genau die Zahl, die der Bug unversehrt ließ. Drei Zusicherungen hätten jeden der hier beschriebenen Defekte gefangen: die Annotationsanzahl auf der erwarteten Seite, ein Feld, das über GetAnnotType oder GetAnnotContentsEx zurückgelesen wird, und ein zweiter Export, der byte für byte mit dem ersten verglichen wird. Dieselbe Disziplin gilt für jede API, die Dokumentstruktur in einer Masse umschreibt, auch für das in Zusammenführen doppelter Formfelder beschriebene Konsolidieren: Prüfen Sie den entstehenden Baum, nicht eine zurückgegebene Summe. Die FDF- und XFDF-Annotationsmethoden mit ihren Datei- und String-Varianten kommen in der losLab PDF Library for Delphi and C++Builder — ab v3.539.30, wenn Kommentare die Reise überleben müssen, ab v3.539.40, wenn der gemeldete Zähler dem entsprechen muss, was tatsächlich gelandet ist