Odborný článok

Import FDF anotácií v Delphi: oprava tichej nuly

Pred v3.539.30 vracala metóda TPDFlib.ImportAnnotationsFromFDFString v losLab PDF Library počet rozparsovaných položiek FDF anotácií, pritom do dokumentu nepridala ani jednu: každá položka sa spočítala, každá sa zahodila. Od v3.539.30 číta FDF importér kľúče v ľubovoľnom poradí, parsuje /Rect korektne a nezávisle od locale a príslušný exportér zapisuje skutočný /Rect anotácie, takže export, import a druhý export dajú bajtovo identické FDF. Zvyšok tejto poznámky vysvetľuje, ako jeden zlý počiatočný offset vyprodukoval dokonalé tiché zlyhanie, aké tri ďalšie defekty sa za ním skrývali a ako si import overiť sami namiesto dôvery v návratovú hodnotu

Scenár je každodenný. Recenzent okomentuje zmluvu, komentáre putujú ako FDF súbor (Acrobat to volá Export Comments) a vaša Delphi služba ich zlúči do čistej kópie cez ImportAnnotationsFromFDF. Volanie vráti 7, v logu stojí „7 komentárov importovaných“, job zozelenie a výstupné PDF nemá žiadny komentár. Nič nevyhodilo výnimku, nič nevarovalo a číslo vyzeralo vierohodne, lebo to bol skutočný počet položiek v súbore. Horšiu podobu chyby sa vymyslieť nedá: funkcia, ktorej jediným signálom úspechu je počítadlo počítané nezávisle od práce, ktorú má hlásiť

Prečo ohlásilo ImportAnnotationsFromFDFString úspech, ale nič nepridalo?

Importér čítal každý /Subtype ako prázdny reťazec a helper, ktorý anotáciu vytvára, skončil skoro pri prázdnom subtype, zatiaľ čo volajúci si výsledok aj tak pripočítal. Hľadač kľúčov vrátil pozíciu bezprostredne za /Subtype, čo je biely priestor pred hodnotou. ReadName štartoval na tej medzere a zastavil sa na prvom bielom znaku, takže skončil skôr, než niečo prečítal. AddAnnotationToPage odmieta staviť anotáciu bez subtype, čo je izolovane vzaté správna defenzívna voľba, ale bola to procedúra bez návratovej hodnoty a Inc(Result) sedel vonku. Každá pojistka bola sama o sebe rozumná; spolu premenili „nic nefungovalo“ na „všetko fungovalo“. Oprava prinúti ReadName preskočiť biely priestor, vyžadovať úvodné / PDF name objektu a zastaviť sa na ľubovoľnom delimitri, vrátane [, ( a ), takže /Subtype/Text aj /Subtype /Text dajú Text

ImportAnnotationsFromFDFString v PDFlibPas našla /Subtype, naštartovala ReadName na bielom priestore za kľúčom, takže vrátila prázdny názov, AddAnnotationToPage skončila pri chýbajúcom subtype a volajúci si výsledok aj tak pripočítal, takže hlásil sedem importovaných komentárov, pritom do dokumentu nepridal žiadny
Každá pojistka bola izolovane rozumná; spolu premenili nic nefungovalo na všetko fungovalo, a presne preto nesmie byť návratová hodnota nikdy jedinou vecou, ktorú test importu kontroluje

Návratová hodnota si zaslúžila pozornosť aj po tejto oprave. Až po v3.539.39 pripočítavalo ImportAnnotationsFromFDFString k výsledku každý korektne utvorený slovník v poli /Annots, vrátane položiek, ktorých 0-based /Page bol mimo rozsahu alebo ktorým chýbal /Subtype, a obe prípady sa preskakujú. Od PDFlibPas v3.539.40 vracajú ImportAnnotationsFromFDFString a ImportAnnotationsFromFDF počet skutočne pridaných anotácií, ako import XFDF: FDF helper AddAnnotationToPage teraz vracia Boolean a počítadlo sa hýbe len pri úspechu. Meranie dokumentu zostáva silnejšou kontrolou, lebo platí aj na starších verziách, takže náčrt nižšie porovnáva AnnotationCount na každej strane pred importom a po ňom

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 vybranú stranu, vrátane widgetov
  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   // rovnaké 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;

Tri ďalšie defekty za prvým

Samotná oprava subtype by odkryla tri ďalšie chyby tej istej funkcie, pričom každá z nich bola neviditeľná len preto, že na stranu sa nikdy nedostala žiadna anotácia. Po prvé, ReadNumber bral pozíciu ako value parameter, takže čítanie štyroch čísel /Rect v rade čítalo štyrikrát to isté miesto, a otváracie [ nepreskočil, takže v praxi neprečítal vôbec nič. Po druhé, FindKey zdieľal jeden dopredu sa pohybujúci kurzor cez všetky vyhľadávania. Exportér zapisuje /Subtype, /Rect, /Page, /Contents, /T, /Subj, ale importér hľadal v poradí /Subtype, /Contents, /T, /Subj, /Page, /Rect; keď kurzor raz prešiel cez /Contents, hľadanie /Page a /Rect bežalo za aktuálnu položku a buď nič nenašlo, alebo chytilo kľúče nasledujúcej anotácie. Knižnica nedokázala prečítať vlastný výstup. Po tretie, čísla išli cez PLStrToFloat, ktorý sa riadi desatinným oddeľovačom systému. ISO 32000-1 §12.7.7 definuje FDF ako syntax PDF objektov a kľúče slovníkov sú v PDF neusporiadané (§7.3.7), takže každý FDF parser, ktorý predpokladá poradie kľúčov, je od základu zlý, nech súbor vyrobil ktorýkoľvek nástroj

Opravený importér najprv ohraničí každú položku. FindDictEnd prechádza od otváracej << k jej páru >>, sleduje vnorené slovníky a preskakuje telá literálnych reťazcov aj s ich backslash escapmi, takže >> vnútri komentára ako (see section >> 4) nemôže položku ukončiť skoro. Každé vyhľadanie kľúča potom štartuje na vlastnom začiatku položky a je obmedzené jej koncom, čo urobí poradie kľúčov nevýznamným a zabráni jednej anotácii požičať si /Page inej. Zhoda kľúča tiež akceptuje delimiter priamo za názvom, lebo /Contents(Hi) je tak isto platné ako /Contents (Hi), zatiaľ čo pravidlo hranice slova bráni /Subj chytiť začiatok /Subtype a /T chytiť /Type. ReadNumber teraz berie pozíciu ako var parameter, preskočí biely priestor a [ a parsuje cez PLTryStrToFloatInvariant, ktorý pri deformovanom tokene zlyhá mäkko namiesto vyhodenia výnimky. Keď zlyhá ktorékoľvek zo štyroch čísel obdĺžnika, všetky štyri sa vrátia na nulu namiesto výroby napoly prečítaného obdĺžnika

FindDictEnd v PDFlibPas teraz ohraničuje každú FDF anotáciu od jej otváracej << po párovú >>, takže každé vyhľadanie kľúča reštartuje na začiatku položky a zastaví sa na jej konci, a ReadNumber berie pozíciu ako var, preskočí zátvorku a parsuje cez PLTryStrToFloatInvariant
Zdieľaný kurzor nedokázal prečítať vlastný export knižnice: keď prešiel cez /Contents, hľadania /Page a /Rect nabehli do kľúčov nasledujúcej anotácie, takže poradie kľúčov už nesmie hrať žiadnu úlohu

Prečo posúvali FDF round-tripy každú anotáciu o jej vlastnú výšku?

Starý exportér zapisoval obdĺžnik v nesprávnom súradnicovom modeli. /Rect anotácie je [llx lly urx ury] v predvolenom user space (ISO 32000-1 §12.5.2, obdĺžniky definuje §7.9.5) a FDF nesie to isté pole. ExportAnnotationsToFDFString však volala GetAnnotRectEx, ktorý hlási Left, Top, Width a Height v kresliacich súradniciach knižnice, teda v priestore, ktorý ovláda SetOrigin, a serializovala ich ako [L T L+W T+H]. Importér tieto štyri hodnoty po svojej oprave zapísal späť slovo od slova ako PDF obdĺžnik, takže horná hrana pristála tam, kam patrí ľavý dolný roh, a každý round trip posunul anotáciu nahor o jej vlastnú výšku. Exportér teraz kopíruje vlastné čísla /Rect anotácie, tri desatinné miesta, bodkový oddeľovač, žiadny exponent, a na vypočítaný obdĺžnik spadne len vtedy, keď uložené pole chýba alebo nemá štyri čísla

PDFlibPas serializoval FDF /Rect ako left, top, width, height v kresliacich súradniciach, takže import týchto štyroch čísel späť ako llx lly urx ury pristál s hornou hranou tam, kam patrí ľavý dolný roh, a posúval každú anotáciu nahor o jej vlastnú výšku pri každom round tripe
Exportér teraz kopíruje vlastné čísla /Rect anotácie — tri desatinné miesta, bodkový oddeľovač, žiadny exponent — a regresný test porovnáva druhý export bajt po bajte s prvým

Regresný test, ktorý toto zaistí, stojí za skopírovanie, lebo asertuje na dokument a na druhý export, nie na návratovú hodnotu importéra. Všimnite si očakávaný počet 2: AddNoteAnnotation vytvorí Text anotáciu plus jej Popup a cestujú obaja. Test navyše spúšťa export a import pod čiarkovým desatinným oddeľovačom, a presne tam býva druhá polovica tohto príbehu

var
  Source, Target: TPDFlib;
  FDF: AnsiString;
  OldSep: Char;
begin
  Source := TPDFlib.Create;
  Target := TPDFlib.Create;
  try
    Source.NewPages(1);                     // teraz dve strany
    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 := ',';   // simulácia nemeckej či francúzskej plochy
    try
      FDF := Source.ExportAnnotationsToFDFString;   // stále zapisuje /Rect [50.5 ...
      Target.ImportAnnotationsFromFDFString(FDF);
    finally
      FormatSettings.DecimalSeparator := OldSep;
    end;

    Target.SelectPage(2);
    Assert(Target.AnnotationCount = 2);           // poznámka aj jej popup
    Assert(Target.GetAnnotType(1) = 'Text');
    Assert(Target.ExportAnnotationsToFDFString = Source.ExportAnnotationsToFDFString);
  finally
    Target.Free;
    Source.Free;
  end;
end;

Buďte v jasnom tom, čo FDF cesta nesie. Importér si každú položku postaví nanovo ako slovník s /Type, /Subtype, /Rect, /Contents, /T a /Subj; farba, flagy, štýl okraja, popup odkazy a appearance streamy nie sú súčasťou tejto cesty a exportér preskočí anotácie Widget, lebo formulárové polia patria metódam form-data. Širšia mapa, ktoré dáta putujú ktorou metódou, je v prehľade o výmene formulárových dát FDF, XFDF a XFA a ak potrebujete skontrolovať, čo skutočne dorazilo, čítačky na konkrétny index ako GetAnnotType, GetAnnotTitle a GetAnnotContentsEx popisuje článok o introspekcii outline, anotácií a akcií

Ako čítať FDF a XFDF súbory s čiarkovými desatinnými zo starších exportov?

Pre FDF je odpoveď jednoznačná: čiarka nie je v PDF syntaxi delimiter, takže číselný token, ktorý obsahuje presne jednu čiarku a žiadnu bodku, môže byť len desatinné číslo zapísané na stroji s čiarkovým locale. Staršie verzie také súbory naozaj zapisovali, napríklad /Rect [10,500 20,250 40,750 60,125], a nový ReadNumber premení tú jedinú čiarku na bodku ešte pred parsovaním. Token s dvoma čiarkami, alebo s čiarkou a bodkou súčasne, sa odmietne namiesto hádania. Čítačka ani neprijíma exponentový zápis, čo sedí s ISO 32000-1 §7.3.3: PDF čísla ho nikdy nepoužívajú

XFDF je ťažšie, lebo v XML atribútoch je čiarka oddeľovačom. Štandardný XFDF (ISO 19444-1) zapisuje rect="50.5,80.25,70.75,100.125" a dashes="4,2", zatiaľ čo v3.539.28 a staršie na systéme s čiarkovým locale zapisovali rect="50,500 80,250 70,750 100,125" a opacity="0,600" a navyše padali s EConvertError pri čítaní štandardného opacity="0.6". Od v3.539.29 sú oba smery invariantné a dedičný tvar rozpozná XFDFNormalizeLegacyDecimals len vtedy, keď sa atribút rozdelí bielym priestorom presne na očakávaný počet tokenov (štyri pre rect, jeden pre opacity a width) a každý token má tvar číslice-čiarka-číslice. Štandardný rect nikdy nesedí: je to buď jeden token s tromi čiarkami, alebo tokeny končiace čiarkou. dashes sa zámerne necháva pokoji, lebo 4,2 môže byť dvojica dĺžok v dash vzore alebo dedičné 4.2 a žiadne pravidlo ich neodlíši

const
  // Kľúče v poradí odlišnom od exportéra plus čiarkové decimály zo staršieho exportu s čiarkovým locale
  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;               // čerstvý dokument má jednu stranu
  try
    Lib.ImportAnnotationsFromFDFString(LegacyFDF);
    Assert(Lib.AnnotationCount = 1);
    Assert(Lib.GetAnnotTitle(1) = 'Alpha');
    // Re-export do XFDF s bodkovými decimálmi: rect="10.500 20.250 40.750 60.125"
    Writeln(Lib.ExportAnnotationsToXFDFString);
  finally
    Lib.Free;
  end;
end;

Na čo má test importu anotácií vlastne asertovať?

Užitočný test importu asertuje na stav cieľového dokumentu, nikdy len na to, čo importér hovorí o sebe. Nič v test suite nekontrolovalo AnnotationCount po FDF importe a návratová hodnota, jediné číslo, na ktoré sa niekto pozerá, bola tá jediná, ktorú defekt nechal nedotknutú. Tri asercie by chytili každý defekt popísaný tu: počet anotácií na očakávanej strane, jedno pole načítané späť cez GetAnnotType alebo GetAnnotContentsEx a druhý export porovnaný bajt po bajte s prvým. Rovnaká disciplína platí pre každé API, ktoré hromadne prepisuje štruktúru dokumentu, vrátane konsolidácie polí popísanej v článku o spájaní duplicitných formulárových polí: kontrolujte výsledný strom, nie vrátený súčet. Metódy FDF a XFDF anotácií so svojimi súborovými aj reťazcovými variantmi dodávame v losLab PDF Library for Delphi and C++Builder; bežte na v3.539.30 a novšiu, keď majú komentáre prežiť cestu, a na v3.539.40 a novšiu, keď má vrátený počet sedieť s tým, čo sa naozaj pridalo