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