Tehnički članak

Uvoz FDF anotacija u Delphiju: ispravka tihe nule

Pre v3.539.30, TPDFlib.ImportAnnotationsFromFDFString u losLab PDF Library vraćao je broj FDF unosa anotacija koje je parsirao, a da u dokument nije dodao ni jedan: svaki unos je izbrojan, svaki unos je bačen. Od v3.539.30 FDF uvoznik čita ključeve u bilo kom redosledu, parsira /Rect ispravno i nezavisno od lokalnih podešavanja, a odgovarajući izvoznik upisuje pravi /Rect anotacije, pa izvoz, uvoz i drugi izvoz daju bajt-identičan FDF. Ostatak ove beleške objašnjava kako je jedan pogrešan početni offset proizveo savršeno tiho otkazivanje, koji su se tri dalja defekta krila iza njega, i kako da sami proverite uvoz umesto da verujete povratnoj vrednosti

Scenario je običan. Recenzent obeleži ugovor, komentari putuju kao FDF fajl (Acrobat to zove Export Comments), a vaš Delphi servis spaja ih u čistu kopiju pomoću ImportAnnotationsFromFDF. Poziv vrati 7, log kaže „uvezeno 7 komentara“, posao zasvetli zeleno, a izlazni PDF nema nijedan komentar. Ništa nije podiglo grešku, ništa nije upozorilo, a broj je delovao verodostojno jer je zaista bio tačan broj unosa u fajlu. To je najgori oblik koji bug može da uzme: funkcija čiji je jedini signal uspeha brojač koji se računa nezavisno od posla za koji tvrdi da ga izveštava

Zašto je ImportAnnotationsFromFDFString javio uspeh a nije dodao ništa?

Uvoznik je svaki /Subtype čitao kao prazan string, a pomoćna funkcija koja kreira anotaciju izlazi rano kod praznog subtipa dok pozivalac svejedno uvećava rezultat. Tražilica ključa vraćala je poziciju odmah iza /Subtype, a to je belina ispred vrednosti. ReadName je startovao na tom razmaku i stajao na prvoj belini, pa je stao pre nego što je išta pročitao. AddAnnotationToPage odbija da gradi anotaciju bez subtipa, što je izolovano gledano ispravna defanzivna odluka, ali je bila procedura bez povratne vrednosti, a Inc(Result) je stajao spolja. Svaka obrana je sama za sebe bila razumna; zajedno su pretvorile „ništa nije radilo“ u „sve je radilo“. Popravka čini da ReadName preskoči beline, zahteva vodeći / PDF name objekta i staje na bilo kom delimiteru, uključujući [, ( i ), pa i /Subtype/Text i /Subtype /Text daju Text

PDFlibPas ImportAnnotationsFromFDFString pronašao je /Subtype, startovao ReadName na belini iza ključa pa vratio prazno ime, AddAnnotationToPage izašao zbog nedostajućeg subtipa, a pozivalac svejedno uvećao rezultat i prijavio sedam uvezenih komentara ne dodavši nijedan u dokument
Svaka obrana je izolovano bila razumna; zajedno su pretvorile ništa nije radilo u sve je radilo, i zato povratna vrednost nikada ne sme biti jedino što test uvoza proverava

Povratna vrednost je zasluživala pažnju i posle te popravke. Do v3.539.39 uključivo, ImportAnnotationsFromFDFString je i dalje uvećavao rezultat za svaki korektno oblikovan rečnik u nizu /Annots, uključujući unose čiji je 0-bazirani /Page bio van opsega ili kojima je /Subtype nedostajao, a oba slučaja se preskaču. Od PDFlibPas v3.539.40, ImportAnnotationsFromFDFString i ImportAnnotationsFromFDF vraćaju broj stvarno dodatih anotacija, kao i XFDF uvoz: FDF pomoćna funkcija AddAnnotationToPage sada vraća Boolean, a brojač se pomera samo pri uspehu. Merenje dokumenta je i dalje jača provera, jer važi i na starijim verzijama, pa skica ispod poredi AnnotationCount na svakoj stranici pre i posle uvoza

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);   // po izabranoj stranici, widget-i uključeni
  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   // jednako 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;

Još tri defekta iza prvog

Popravka samo subtipa otkrila bi još tri buga u istoj funkciji, od kojih je svaki ostao nevidljiv samo zato što nijedna anotacija nikada nije stigla do stranice. Prvo, ReadNumber je poziciju primao kao value parametar, pa je čitanje četiri /Rect broja zaredom čitalo isto mesto četiri puta, i nije preskakalo otvarajući [, pa u praksi nije pročitalo ništa. Drugo, FindKey je delio jedan unapred sekući kursor među svim pretragama. Izvoznik upisuje /Subtype, /Rect, /Page, /Contents, /T, /Subj, ali je uvoznik tražio redom /Subtype, /Contents, /T, /Subj, /Page, /Rect; kad je kursor jednom prešao /Contents, pretraga za /Page i /Rect jurila je preko tekućeg unosa i ili nije nalazila ništa ili je poklapala ključeve sledeće anotacije. Biblioteka nije mogla da pročita sopstveni izlaz. Treće, brojevi su išli kroz PLStrToFloat, koji prati decimalni separator sistema. ISO 32000-1 §12.7.7 definiše FDF kao sintaksu PDF objekata, a ključevi rečnika u PDF-u su neuređeni (§7.3.7), pa je svaki FDF parser koji pretpostavlja redosled ključeva pogrešan po konstrukciji, ma koji alat proizveo fajl

Popravljeni uvoznik prvo ograđuje svaki unos. FindDictEnd šeta od otvarajućeg << do njemu odgovarajućeg >>, prateći ugnježđene rečnike i preskakajući tela literalnih stringova sa njihovim backslash escape-ovima, pa >> unutar komentara poput (see section >> 4) ne može preuranjeno zatvoriti unos. Svaka pretraga ključa potom startuje od početka samog unosa i ograničena je njegovim krajem, što redosled ključeva čini nebitnim i sprečava jednu anotaciju da pozajmi tuđi /Page. Poklapanje ključa prihvata i delimiter odmah iza imena, jer je /Contents(Hi) jednako validno kao /Contents (Hi), dok pravilo o granici reči sprečava /Subj da poklopi početak od /Subtype i /T da poklopi /Type. ReadNumber sada poziciju prima kao var parametar, preskače beline i [, i parsira uz PLTryStrToFloatInvariant, koji blago otkazuje na neispravnom tokenu umesto da podigne grešku. Ako bilo koji od četiri broja pravougaonika omane, sva četiri padaju na nulu umesto da proizvedu upola pročitan pravougaonik

PDFlibPas FindDictEnd sada ograđuje svaku FDF anotaciju od njenog otvarajućeg << do odgovarajućeg >>, pa svaka pretraga ključa startuje od početka unosa i staje na njegovom kraju, a ReadNumber poziciju prima kao var, preskače uglastu zagradu i parsira uz PLTryStrToFloatInvariant
Deljeni kursor nije mogao da pročita sopstveni izvoz biblioteke: kad je jednom prešao /Contents, pretrage za /Page i /Rect udarale su u ključeve sledeće anotacije, pa redosled ključeva više nikako ne sme biti bitan

Zašto je svaki FDF round trip pomerao svaku anotaciju za njenu sopstvenu visinu?

Stari izvoznik upisivao je pravougaonik u pogrešnom koordinatnom modelu. /Rect anotacije je [llx lly urx ury] u podrazumevanom user space-u (ISO 32000-1 §12.5.2, uz pravougaonike definisane u §7.9.5), i FDF nosi isti niz. ExportAnnotationsToFDFString je međutim zvao GetAnnotRectEx, koji prijavljuje Left, Top, Width i Height u crtaćim koordinatama biblioteke, prostoru kojim upravlja SetOrigin, i serijalizovao ih kao [L T L+W T+H]. Uvoznik je, kad je već radio, ta četiri broja upisivao naziv doslovno kao PDF pravougaonik, pa je gornja ivica sletela tamo gde je pripao donji levi ugao i svaki round trip pomerio anotaciju gore za njenu sopstvenu visinu. Izvoznik sada kopira sopstvene brojeve /Rect anotacije, tri decimale, tačka kao separator, bez eksponenta, i na izračunati pravougaonik pada samo kad sačuvanog niza nema ili nema tačno četiri broja

PDFlibPas je FDF /Rect serijalizovao kao left, top, width, height u crtaćim koordinatama, pa je uvoz tih četiri broja nazad kao llx lly urx ury gornju ivicu slao tamo gde pripada donji levi ugao i na svakom round tripu pomerao svaku anotaciju gore za njenu sopstvenu visinu
Izvoznik sada kopira sopstvene /Rect brojeve anotacije — tri decimale, tačka kao separator, bez eksponenta — a regresioni test drugi izvoz poredi bajt po bajt sa prvim

Regresioni test koji ovo zakucava vredi iskopirati, jer tvrdnje postavlja na dokument i na drugi izvoz, a ne na povratnu vrednost uvoznika. Obratite pažnju na očekivani broj 2: AddNoteAnnotation kreira Text anotaciju plus njen Popup, i obe putuju. Test takođe pokreće izvoz i uvoz uz zarez kao decimalni separator, a tu živi i druga polovina ove priče

var
  Source, Target: TPDFlib;
  FDF: AnsiString;
  OldSep: Char;
begin
  Source := TPDFlib.Create;
  Target := TPDFlib.Create;
  try
    Source.NewPages(1);                     // sada dve stranice
    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 := ',';   // simulacija nemačke ili francuske radne površine
    try
      FDF := Source.ExportAnnotationsToFDFString;   // i dalje upisuje /Rect [50.5 ...
      Target.ImportAnnotationsFromFDFString(FDF);
    finally
      FormatSettings.DecimalSeparator := OldSep;
    end;

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

Budite precizni oko toga šta FDF putanja nosi. Uvoznik svaki unos ponovo gradi kao rečnik sa /Type, /Subtype, /Rect, /Contents, /T i /Subj; boja, flags, stil ivice, popup veze i appearance streamovi nisu deo ove rute, a izvoznik preskače Widget anotacije jer form polja pripadaju metodama za form podatke. Šira mapa toga koji podaci putuju kojom metodom je u pregledu razmene FDF, XFDF i XFA form podataka, a ako treba da pogledate šta je stvarno stiglo, čitači po indeksu poput GetAnnotType, GetAnnotTitle i GetAnnotContentsEx pokriveni su u tekstu o introspekciji outline-ova, anotacija i akcija

Kako čitati FDF i XFDF fajlove sa zapetim decimalama iz starijih izvoza?

Za FDF je odgovor nedvosmislen: zarez u PDF sintaksi nije delimiter, pa numerički token koji ima tačno jedan zarez i nijednu tačku može biti samo decimala napisana na mašini sa zapetim lokalom. Ranije verzije su zaista upisivale takve fajlove, na primer /Rect [10,500 20,250 40,750 60,125], i novi ReadNumber taj jedini zarez pretvara u tačku pre parsiranja. Token sa dva zareza, ili zarezom i tačkom, odbija se umesto da se pogađa. Čitač ne konzumira ni eksponencijalni zapis, što se poklapa sa ISO 32000-1 §7.3.3: PDF brojevi ga nikada ne koriste

XFDF je gorem slučaj, jer je u XML atributima zarez separator. Standardni XFDF (ISO 19444-1) piše rect="50.5,80.25,70.75,100.125" i dashes="4,2", dok su v3.539.28 i ranije, na sistemu sa zapetim lokalom, upisivali rect="50,500 80,250 70,750 100,125" i opacity="0,600", i pri čitanju standardnog opacity="0.6" padali su sa EConvertError. Od v3.539.29 oba smera su invarijantna, a nasleđeni oblik prepoznaje XFDFNormalizeLegacyDecimals samo kad se atribut po belinama raspadne na tačno očekivani broj tokena (četiri za rect, po jedan za opacity i width) i svaki token ima oblik cifre-zarez-cifre. Standardni rect se nikada ne poklopi: on je ili jedan token sa tri zareza, ili tokeni koji se završavaju zarezom. dashes je namerno ostavljen na miru, jer 4,2 mogu biti dve dužine crtica ili nasleđeno 4.2, i nijedno pravilo ih ne može razlikovati

const
  // Ključevi van izvoznikovog redosleda, plus zapete decimale iz starijeg izvoza sa zapetim lokalom
  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;               // svež dokument ima jednu stranicu
  try
    Lib.ImportAnnotationsFromFDFString(LegacyFDF);
    Assert(Lib.AnnotationCount = 1);
    Assert(Lib.GetAnnotTitle(1) = 'Alpha');
    // Reeksportovano kao XFDF sa tačkastim decimalama: rect="10.500 20.250 40.750 60.125"
    Writeln(Lib.ExportAnnotationsToXFDFString);
  finally
    Lib.Free;
  end;
end;

Šta test uvoza anotacija zaista treba da tvrdi?

Korisni test uvoza postavlja tvrdnje na stanje ciljnog dokumenta, nikada samo na ono što uvoznik kaže o sebi. Ništa u test skupu nije proveravalo AnnotationCount posle FDF uvoza, a povratna vrednost, jedini broj koji je iko gledao, bila je upravo jedini broj koji je bug ostavio netaknutim. Tri tvrdnje bi uhvatile svaki opisani defekt: broj anotacija na očekivanoj stranici, jedno polje pročitano nazad kroz GetAnnotType ili GetAnnotContentsEx, i drugi izvoz poređen bajt po bajt sa prvim. Ista disciplina važi za svaki API koji u jednom potezu prepravlja strukturu dokumenta, uključujući konsolidaciju polja opisanu u tekstu o spajanju dupliranih form polja: proverite nastalo stablo, ne vraćeni zbir. FDF i XFDF metode za anotacije, sa svojim fajl i string varijantama, isporučuju se u losLab PDF Library for Delphi and C++Builder, a v3.539.30 ili novija je verzija koju treba držati ako komentari moraju preživeti put, v3.539.40 ili novija ako vraćeni broj mora da odgovara onome što je dodato