Artykuł techniczny

Import adnotacji FDF w Delphi: naprawa cichego zera

Przed v3.539.30 TPDFlib.ImportAnnotationsFromFDFString w losLab PDF Library zwracał liczbę wpisów adnotacji FDF, które sparsował, nie dodając żadnego do dokumentu: każdy wpis był liczony i każdy był wyrzucany. Od v3.539.30 importer FDF czyta klucze w dowolnej kolejności, parsuje /Rect poprawnie i niezależnie od locale, a pasujący eksporter zapisuje prawdziwy /Rect adnotacji, więc eksport, import i drugi eksport dają bajt w bajt identyczne FDF. Reszta tej notatki wyjaśnia, jak jeden zły offset startowy wyprodukował wzorcową cichą awarię, jakie trzy inne defekty kryły się za nim i jak samodzielnie sprawdzić import, zamiast ufać wartości zwracanej

Scenariusz jest zwyczajny. Recenzent komentuje umowę, komentarze podróżują jako plik FDF (Acrobat nazywa to Export Comments), a twoja usługa w Delphi scala je z czystą kopią przez ImportAnnotationsFromFDF. Wywołanie zwraca 7, log mówi „zaimportowano 7 komentarzy", job świeci na zielono, a wyjściowy PDF nie ma komentarzy wcale. Nic nie rzuciło wyjątku, nic nie ostrzegło, a liczba wyglądała wiarygodnie, bo była prawdziwym licznikiem wpisów w pliku. To najgorszy kształt, jaki błąd może przybrać: funkcja, której jedynym sygnałem sukcesu jest licznik liczony niezależnie od pracy, o którą rzekomo raportuje

Dlaczego ImportAnnotationsFromFDFString zgłaszał sukces, ale nic nie dodawał?

Importer czytał każdy /Subtype jako pusty łańcuch, a helper tworzący adnotację wychodził wcześniej przy pustym subtypie, podczas gdy wywołujący i tak inkrementował wynik. Wyszukiwarka klucza zwracała pozycję natychmiast za /Subtype, czyli biały znak przed wartością. ReadName zaczynał na tej spacji i zatrzymywał się na pierwszym białym znaku, więc kończył, zanim cokolwiek przeczytał. AddAnnotationToPage odmawia budowy adnotacji bez subtypu, co w izolacji jest poprawnym wyborem defensywnym, ale była to procedura bez wartości zwracanej, a Inc(Result) siedział na zewnątrz. Każdy z tych strażników był rozsądny osobno; razem zamieniły „nic nie działało" w „wszystko działało". Poprawka sprawia, że ReadName pomija białe znaki, wymaga wiodącego / obiektu nazwy PDF i zatrzymuje się na dowolnym delimiterze, w tym [, ( i ), więc /Subtype/Text i /Subtype /Text dają oba Text

ImportAnnotationsFromFDFString w PDFlibPas znajdował /Subtype, startował ReadName na białym znaku za kluczem, więc dostawał pustą nazwę, AddAnnotationToPage wychodził przy brakującym subtypie, a wywołujący i tak inkrementował wynik, raportując siedem zaimportowanych komentarzy bez dodania żadnego do dokumentu
Każdy strażnik był rozsądny osobno; razem zamieniły nic nie działało w wszystko działało, dlatego wartość zwracana nigdy nie może być jedyną rzeczą, jaką sprawdza test importu

Wartość zwracana zasługiwała na uwagę nawet po tej poprawce. Do v3.539.39 ImportAnnotationsFromFDFString nadal inkrementował wynik dla każdego dobrze uformowanego słownika w tablicy /Annots, łącznie z wpisami, których 0-bazowy /Page był poza zakresem albo którym brakowało /Subtype, a oba przypadki są pomijane. Od PDFlibPas v3.539.40 ImportAnnotationsFromFDFString i ImportAnnotationsFromFDF zwracają liczbę faktycznie dodanych adnotacji, jak import XFDF: helper FDF AddAnnotationToPage zwraca teraz Boolean, a licznik rusza tylko przy sukcesie. Pomiar dokumentu pozostaje mocniejszym sprawdzeniem, bo działa także na starszych wersjach, więc szkic poniżej porównuje AnnotationCount na każdej stronie przed importem i po nim

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);   // na każdej wybranej stronie, widżety wliczone
  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   // równe od 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;

Trzy kolejne defekty za pierwszym

Sama naprawa subtypu obnażyłaby trzy dalsze błędy tej samej funkcji, każdy niewidoczny tylko dlatego, że żadna adnotacja nigdy nie dotarła na stronę. Po pierwsze, ReadNumber brał pozycję jako parametr wartości, więc czytanie czterech liczb /Rect po kolei czytało to samo miejsce cztery razy, a nie pomijało otwierającego [, więc w praktyce nie czytał niczego wcale. Po drugie, FindKey dzielił jeden przesuwający się naprzód kursor między wszystkie wyszukiwania. Eksporter pisze /Subtype, /Rect, /Page, /Contents, /T, /Subj, ale importer szukał w kolejności /Subtype, /Contents, /T, /Subj, /Page, /Rect; gdy kursor minął /Contents, szukanie /Page i /Rect biegło za koniec bieżącego wpisu i albo nie znajdowało nic, albo łapało klucze następnej adnotacji. Biblioteka nie potrafiła przeczytać własnego wyjścia. Po trzecie, liczby szły przez PLStrToFloat, który stosuje systemowy separator dziesiętny. ISO 32000-1 §12.7.7 definiuje FDF jako składnię obiektów PDF, a klucze słowników w PDF są nieuporządkowane (§7.3.7), więc każdy parser FDF zakładający kolejność kluczy jest zły z konstrukcji, jakiekolwiek narzędzie wyprodukowało plik

Naprawiony importer najpierw wyznacza granice każdego wpisu. FindDictEnd idzie od otwierającego << do pasującego >>, śledząc zagnieżdżone słowniki i pomijając ciała łańcuchów literalnych z ich ucieczkami po ukośniku, więc >> wewnątrz komentarza takiego jak (see section >> 4) nie skończy wpisu za wcześnie. Każde wyszukiwanie klucza startuje potem na własnym początku wpisu i jest ograniczone jego końcem, co czyni kolejność kluczy bez znaczenia i nie pozwala jednej adnotacji pożyczyć /Page drugiej. Dopasowanie klucza akceptuje też delimiter bezpośrednio po nazwie, bo /Contents(Hi) jest równie poprawne jak /Contents (Hi), a reguła granicy słowa pilnuje, by /Subj nie złapało początku /Subtype, a /T — /Type. ReadNumber bierze teraz pozycję jako parametr var, pomija białe znaki i [, i parsuje przez PLTryStrToFloatInvariant, który miękko zawodzi na zdeformowanym tokenie zamiast rzucać wyjątek. Gdy któraś z czterech liczb prostokąta zawiedzie, wszystkie cztery wracają do zera, zamiast produkować półprzeczytany prostokąt

FindDictEnd w PDFlibPas wyznacza teraz granice każdej adnotacji FDF od otwierającego << do pasującego >>, więc każde wyszukiwanie klucza startuje na początku wpisu i stopuje na jego końcu, a ReadNumber bierze pozycję var, pomija nawias i parsuje przez PLTryStrToFloatInvariant
Wspólny kursor nie potrafił przeczytać własnego eksportu biblioteki: gdy minął /Contents, szukania /Page i /Rect wbiegały w klucze następnej adnotacji, więc kolejność kluczy nie może już niczego zmieniać

Dlaczego pełne cykle FDF przesuwały każdą adnotację o jej własną wysokość?

Stary eksporter zapisywał prostokąt w złym modelu współrzędnych. /Rect adnotacji to [llx lly urx ury] w domyślnej przestrzeni użytkownika (ISO 32000-1 §12.5.2, prostokąty zdefiniowane w §7.9.5), a FDF wozi tę samą tablicę. ExportAnnotationsToFDFString wołał jednak GetAnnotRectEx, który raportuje Left, Top, Width i Height w rysunkowych współrzędnych biblioteki, tej przestrzeni, którą steruje SetOrigin, i serializował je jako [L T L+W T+H]. Importer, gdy już zaczął działać, zapisywał te cztery wartości z powrotem dosłownie jako prostokąt PDF, więc górna krawędź lądowała tam, gdzie należał się lewy dolny róg, a każdy pełny cykl przesuwał adnotację w górę o jej własną wysokość. Eksporter kopiuje teraz własne liczby /Rect adnotacji, trzy miejsca dziesiętne, separator kropka, bez wykładnika, i wraca do prostokąta policzonego tylko wtedy, gdy zapisana tablica nie istnieje albo nie ma czterech liczb

PDFlibPas serializował kiedyś FDF /Rect jako left, top, width, height we współrzędnych rysunkowych, więc zaimportowanie tych czterech liczb z powrotem jako llx lly urx ury lądowało górną krawędzią tam, gdzie należał się lewy dolny róg, i przesuwało każdą adnotację w górę o jej własną wysokość przy każdym pełnym cyklu
Eksporter kopiuje teraz własne liczby /Rect adnotacji — trzy miejsca dziesiętne, separator kropka, bez wykładnika — a test regresji porównuje drugi eksport bajt w bajt z pierwszym

Test regresji, który to przypina, warto skopiować, bo asertuje na dokumencie i na drugim eksporcie, a nie na wartości zwracanej przez importera. Zwróć uwagę na oczekiwaną liczbę 2: AddNoteAnnotation tworzy adnotację Text plus jej Popup i obie podróżują. Test uruchamia też eksport i import przy separatorze dziesiętnym przecinek, i tu mieszka druga połowa tej historii

var
  Source, Target: TPDFlib;
  FDF: AnsiString;
  OldSep: Char;
begin
  Source := TPDFlib.Create;
  Target := TPDFlib.Create;
  try
    Source.NewPages(1);                     // teraz dwie strony
    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 := ',';   // symulacja niemieckiego albo francuskiego pulpitu
    try
      FDF := Source.ExportAnnotationsToFDFString;   // nadal pisze /Rect [50.5 ...
      Target.ImportAnnotationsFromFDFString(FDF);
    finally
      FormatSettings.DecimalSeparator := OldSep;
    end;

    Target.SelectPage(2);
    Assert(Target.AnnotationCount = 2);           // notatka i jej popup
    Assert(Target.GetAnnotType(1) = 'Text');
    Assert(Target.ExportAnnotationsToFDFString = Source.ExportAnnotationsToFDFString);
  finally
    Target.Free;
    Source.Free;
  end;
end;

Miej jasność co do tego, co wozi ścieżka FDF. Importer przebudowuje każdy wpis jako słownik z /Type, /Subtype, /Rect, /Contents, /T i /Subj; kolor, flagi, styl obramowania, łącza popup i strumienie wyglądu nie są częścią tej trasy, a eksporter pomija adnotacje Widget, bo pola formularza należą do metod danych formularza. Szersza mapa, jakie dane podróżują którą metodą, jest w przeglądzie wymiany danych formularzy FDF, XFDF i XFA, a jeśli musisz obejrzeć, co faktycznie dotarło, czytniki per indeks, takie jak GetAnnotType, GetAnnotTitle i GetAnnotContentsEx, są omówione w artykule o introspekcji konspektów, adnotacji i akcji

Jak czytać FDF i XFDF z przecinkiem dziesiętnym ze starszych eksportów?

Dla FDF odpowiedź jest jednoznaczna: przecinek nie jest delimiterem w składni PDF, więc token liczbowy zawierający dokładnie jeden przecinek i żadnej kropki może być tylko dziesiętnym zapisanym na maszynie z locale przecinkowym. Wcześniejsze wersje faktycznie pisały takie pliki, na przykład /Rect [10,500 20,250 40,750 60,125], a nowy ReadNumber zamienia ten pojedynczy przecinek na kropkę przed parsowaniem. Token z dwoma przecinkami albo z przecinkiem i kropką jest odrzucany, a nie zgadywany. Czytnik nie konsumuje też notacji wykładniczej, co zgadza się z ISO 32000-1 §7.3.3: liczby PDF nigdy jej nie używają

XFDF jest trudniejszy, bo w atrybutach XML przecinek jest separatorem. Standardowy XFDF (ISO 19444-1) pisze rect="50.5,80.25,70.75,100.125" i dashes="4,2", podczas gdy v3.539.28 i wcześniejsze, na systemie z locale przecinkowym, pisały rect="50,500 80,250 70,750 100,125" i opacity="0,600", a dodatkowo wywalały się z EConvertError przy czytaniu standardowego opacity="0.6". Od v3.539.29 oba kierunki są niezmienne wobec locale, a starszy kształt rozpoznaje XFDFNormalizeLegacyDecimals tylko wtedy, gdy atrybut dzieli się białymi znakami na dokładnie oczekiwaną liczbę tokenów (cztery dla rect, jeden dla opacity i width) i każdy token ma postać cyfra-przecinek-cyfra. Standardowy rect nigdy nie pasuje: to albo jeden token z trzema przecinkami, albo tokeny kończące się przecinkiem. dashes zostawiono z premedytacją w spokoju, bo 4,2 może być dwiema długościami kresek albo starszym 4.2 i żadna reguła tego nie odróżni

const
  // Klucze w kolejności innej niż eksporter plus dziesiętne z przecinkiem ze starszego eksportu
  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;               // świeży dokument ma jedną stronę
  try
    Lib.ImportAnnotationsFromFDFString(LegacyFDF);
    Assert(Lib.AnnotationCount = 1);
    Assert(Lib.GetAnnotTitle(1) = 'Alpha');
    // Reeksport jako XFDF z kropkowymi dziesiętnymi: rect="10.500 20.250 40.750 60.125"
    Writeln(Lib.ExportAnnotationsToXFDFString);
  finally
    Lib.Free;
  end;
end;

Co test importu adnotacji powinien naprawdę asertować?

Przydatny test importu asertuje stan dokumentu docelowego, nigdy tylko to, co importer mówi o sobie samym. Nic w zestawie testów nie sprawdzało AnnotationCount po imporcie FDF, a wartość zwracana, jedyna liczba, na którą ktokolwiek patrzył, była tą jedyną liczbą, którą błąd zostawił nietkniętą. Trzy asercje złapałyby każdy defekt opisany tutaj: liczbę adnotacji na oczekiwanej stronie, jedno pole przeczytane z powrotem przez GetAnnotType albo GetAnnotContentsEx i drugi eksport porównany bajt w bajt z pierwszym. Ta sama dyscyplina dotyczy każdego API, które hurtowo przepisuje strukturę dokumentu, łącznie z konsolidacją pól opisaną w artykule o scalaniu duplikatów pól formularza: sprawdzaj powstałe drzewo, nie zwróconą sumę. Metody adnotacji FDF i XFDF, z wariantami plikowymi i łańcuchowymi, są częścią losLab PDF Library for Delphi and C++Builder, a v3.539.30 lub nowsza to wersja, na której uruchamiaj, jeśli komentarze mają przeżyć podróż, i v3.539.40 lub nowsza, jeśli zwrócony licznik ma zgadzać się z tym, co dodano