Technický článek

Import anotací z FDF v Delphi: oprava tiché nuly

Před v3.539.30 vracela TPDFlib.ImportAnnotationsFromFDFString v losLab PDF Library počet parsovaných položek anotací z FDF a do dokumentu nepřidala ani jednu: každou položku spočítala, každou zahodila. Od v3.539.30 čte FDF importer klíče v libovolném pořadí, parsuje /Rect správně a nezávisle na locale a navazující exporter zapisuje skutečné /Rect anotace, takže export, import a druhý export vyprodukují bajtově identické FDF. Zbytek tohohle zápisu vysvětluje, jak jeden špatný startovní offset vyrobil dokonalé tiché selhání, které tři další vady se za ním krály a jak si import ověřit sami, místo abyste věřili návratové hodnotě

Scénář je každodenní. Recenzent popíše smlouvu poznámkami, komentáře putují jako soubor FDF (Acrobat tomu říká Export Comments) a váš Delphi servis je přes ImportAnnotationsFromFDF sloučí do čisté kopie. Volání vrátí 7, log hlásí „7 comments imported“, job zezelená a výstupní PDF nemá žádné komentáře. Nic nezvedlo výjimku, nic nevarovalo a číslo vypadalo věrohodně, protože to byl skutečný počet položek v souboru. Horší tvar, jaký si bug může vzít, neexistuje: funkce, jejíž jediným signálem úspěchu je počítadlo počítané nezávisle na práci, kterou má hlásit

Proč ImportAnnotationsFromFDFString hlásila úspěch, ale nepřidala nic?

Importer četl každé /Subtype jako prázdný řetězec a helper, který anotaci vytváří, na prázdném subtype končí dřív, zatímco si volající výsledek přesto přičte. Hledač klíčů vrátil pozici bezprostředně za /Subtype, tedy mezeru před hodnotou. ReadName startovala na té mezeře a zastavila se na prvním bílém znaku, takže nepřečetla vůbec nic. AddAnnotationToPage odmítá stavět anotaci bez subtype, což je izolovaně správné obranné rozhodnutí, ale byla to procedura bez návratové hodnoty a Inc(Result) sedělo vně ní. Každá pojistka byla sama o sobě rozumná; dohromady přetavily „nic nefungovalo“ na „všechno fungovalo“. Oprava činí ReadName schopnou přeskočit bílé znaky, vyžadovat úvodní / PDF name objektu a zastavit se na libovolném delimiteru, včetně [, ( a ), takže /Subtype/Text i /Subtype /Text dají Text

ImportAnnotationsFromFDFString v PDFlibPas našla /Subtype, ReadName startovala na bílém znaku za klíčem, takže vrátila prázdný název, AddAnnotationToPage skončila na chybějícím subtype a volající si výsledek přesto přičetl, takže hlásil sedm importovaných komentářů a do dokumentu nepřidal žádný
Každá pojistka byla sama o sobě rozumná; dohromady přetavily nic nefungovalo na všechno fungovalo, a proto návratová hodnota nikdy nesmí být jediné, co test importu kontroluje

I návratová hodnota si zasloužila péči i po téhle opravě. Až do v3.539.39 si ImportAnnotationsFromFDFString přičítala každý formálně správný slovník v poli /Annots, včetně položek s 0-based /Page mimo rozsah nebo s chybějícím /Subtype, které se přeskočily. Od PDFlibPas v3.539.40 vrací ImportAnnotationsFromFDFString a ImportAnnotationsFromFDF počet anotací, které doopravdy přibyly, stejně jako XFDF import: FDF helper AddAnnotationToPage teď vrací Boolean a počítadlo se hýbe jen při úspěchu. Měření dokumentu je pořád silnější kontrola, protože drží i na starších verzích, takže skica níže porovnává AnnotationCount na každé stránce před a po importu

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 vybranou stránku, včetně widgetů
  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   // od v3.539.40 se rovná
      Writeln(Format('Importer reported %d, %d landed on a page', [Reported, Added]));
    Lib.SaveToFile('contract-reviewed.pdf');
  finally
    Lib.Free;
  end;
end;

Tři další vady za tou první

Opravit jen subtype by odkrylo tři další bugy v téže funkci, každý neviditelný jen proto, že na stránku se nedostala ani jedna anotace. Za prvé, ReadNumber brala svou pozici jako value parametr, takže čtení čtyř čísel /Rect za sebou čtyřikrát četlo totéž místo, a otevírací [ navíc nepřeskakovala, takže v praxi nepřečetla vůbec nic. Za druhé, FindKey sdílela jeden posouvající se kurzor mezi všemi vyhledáváními. Exporter píše /Subtype, /Rect, /Page, /Contents, /T, /Subj, ale importer hledal v pořadí /Subtype, /Contents, /T, /Subj, /Page, /Rect; jakmile kurzor prošel /Contents, hledání /Page a /Rect běželo za konec aktuální položky a buď nenašlo nic, nebo se trefilo do klíčů další anotace. Knihovna nedokázala přečíst vlastní výstup. Za třetí, čísla šla přes PLStrToFloat, které se řídí desetinným oddělovačem systému. ISO 32000-1 §12.7.7 definuje FDF jako syntaxi PDF objektů a klíče slovníků jsou v PDF neuspořádané (§7.3.7), takže jakýkoli FDF parser předpokládající pořadí klíčů je špatný už konstrukcí, ať soubor vyrobil jakýkoli nástroj

Opravený importer si nejdřív ohraničí každou položku. FindDictEnd projde od otevíracího << k párovému >>, sleduje vnořené slovníky a přeskakuje těla literálních řetězců i s jejich backslash escapemi, takže >> uvnitř komentáře jako (see section >> 4) nedokončí položku předčasně. Každé hledání klíče pak startuje na začátku dané položky a končí na jejím konci, takže pořadí klíčů je irelevantní a jedna anotace si nepůjčí cizí /Page. Shoda klíče přijme i delimiter bezprostředně za názvem, protože /Contents(Hi) je stejně validní jako /Contents (Hi), zatímco pravidlo hranice slova brání /Subj, aby se trefilo do začátku /Subtype, a /T do /Type. ReadNumber teď bere pozici jako var parametr, přeskakuje bílé znaky a [ a parsuje přes PLTryStrToFloatInvariant, které na deformovaném tokenu selže měkce místo vyhození výjimky. Když selže kterékoliv ze čtyř obdélníkových čísel, všechny čtyři sestoupí na nulu místo napůl přečteného obdélníku

FindDictEnd v PDFlibPas teď ohraničí každou FDF anotaci od otevíracího << k párovému >>, takže každé hledání klíče znovu startuje na začátku položky a zastavuje se na jejím konci a ReadNumber bere var pozici, přeskakuje hranatou závorku a parsuje přes PLTryStrToFloatInvariant
Sdílený kurzor nedokázal přečíst vlastní export knihovny: jakmile prošel /Contents, hledání /Page a /Rect vběhlo do klíčů další anotace, takže pořadí klíčů už na ničem nesmí záležet

Proč posouvaly FDF round-tripy každou anotaci o její vlastní výšku?

Starý exporter zapisoval obdélník v nesprávném souřadnicovém modelu. /Rect anotace je [llx lly urx ury] ve výchozím user space (ISO 32000-1 §12.5.2, obdélníky definuje §7.9.5) a FDF nese totéž pole. ExportAnnotationsToFDFString ale volala GetAnnotRectEx, které hlásí Left, Top, Width a Height v kreslicích souřadnicích knihovny, prostoru řízeném přes SetOrigin, a serializovala je jako [L T L+W T+H]. Importer, jakmile začal fungovat, zapsal těchto čtyř hodnot zpátky doslova jako PDF obdélník, takže horní hrana dopadla tam, kam patřil levý dolní roh, a každý round-trip posunul anotaci o její vlastní výšku. Exporter teď kopíruje vlastní čísla /Rect anotace, tři desetinná místa, oddělovač tečka, žádný exponent, a na spočtený obdélník sáhne jen tehdy, když uložené pole chybí nebo nemá čtyři čísla

PDFlibPas dřív serializovala FDF /Rect jako left, top, width, height v kreslicích souřadnicích, takže import těchto čtyř čísel zpět jako llx lly urx ury dopadl horní hranou tam, kam patřil levý dolní roh, a posunul každou anotaci o její vlastní výšku při každém round-tripu
Exporter teď kopíruje vlastní čísla /Rect anotace — tři desetinná místa, oddělovač tečka, žádný exponent — a regresní test porovnává druhý export bajt po bajtu s prvním

Regresní test, který tohle přibíjí, stojí za okopírování, protože se upíná na dokument a na druhý export, ne na návratovou hodnotu importera. Všimněte si očekávaného počtu 2: AddNoteAnnotation vytvoří Text anotaci a k ní Popup a putují obě. Test navíc spouští export i import s desetinnou čárkou jako oddělovačem — a tady sídlí druhá půlka tohohle příběhu

var
  Source, Target: TPDFlib;
  FDF: AnsiString;
  OldSep: Char;
begin
  Source := TPDFlib.Create;
  Target := TPDFlib.Create;
  try
    Source.NewPages(1);                     // od teď dvě stránky
    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 := ',';   // simulace německé či francouzské plochy
    try
      FDF := Source.ExportAnnotationsToFDFString;   // pořád zapisuje /Rect [50.5 ...
      Target.ImportAnnotationsFromFDFString(FDF);
    finally
      FormatSettings.DecimalSeparator := OldSep;
    end;

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

Mějte jasno v tom, co FDF cesta nese. Importer znovu postaví každou položku jako slovník s /Type, /Subtype, /Rect, /Contents, /T a /Subj; barva, flagy, styl okraje, odkazy na popup a appearance streamy touto cestou neputují a exporter přeskakuje anotace Widget, protože formulářová pole patří metodám pro form data. Širší mapa toho, která data putují přes kterou metodu, je v přehledu výměny dat FDF, XFDF a XFA, a když potřebujete prohlédnout, co doopravdy dorazilo, čtenáře po indexu jako GetAnnotType, GetAnnotTitle a GetAnnotContentsEx popisuje článek introspekce osnov, anotací a akcí

Jak číst FDF a XFDF soubory s desetinnou čárkou ze starších exportů?

U FDF je odpověď jednoznačná: čárka není v syntaxi PDF delimiter, takže číselný token obsahující přesně jednu čárku a žádnou tečku může být jen desetinné číslo zapsané na stroji s čárkovým locale. Starší verze takové soubory opravdu psaly, například /Rect [10,500 20,250 40,750 60,125], a nové ReadNumber tu jedinou čárku před parsováním přetaví na tečku. Token se dvěma čárkami, nebo s čárkou a tečkou, se odmítne místo hádání. Čtenář nekonzumuje ani exponentový zápis, což sedí k ISO 32000-1 §7.3.3: PDF čísla ho nikdy nepoužívají

U XFDF je to složitější, protože v XML atributech je čárka oddělovač. Standardní XFDF (ISO 19444-1) zapisuje rect="50.5,80.25,70.75,100.125" a dashes="4,2", zatímco v3.539.28 a dřívější psaly na systému s čárkovým locale rect="50,500 80,250 70,750 100,125" a opacity="0,600" a při čtení standardního opacity="0.6" navíc padaly s EConvertError. Od v3.539.29 jsou oba směry invariantní a zastaralý tvar rozpozná XFDFNormalizeLegacyDecimals, jen když se atribut rozdělí podle bílých znaků přesně na očekávaný počet tokenů (čtyři pro rect, jeden pro opacity a width) a každý token má tvar číslice-čárka-číslice. Standardní rect nikdy nesedí: je to buď jeden token se třemi čárkami, nebo tokeny končící čárkou. dashes se záměrně nechává na pokoji, protože 4,2 může být dvě délky čárkování i zastaralé 4.2 a žádné pravidlo je nerozezná

const
  // Klíče mimo exporterovo pořadí, plus čárkové decimály ze staršího exportu v čárkové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 stránku
  try
    Lib.ImportAnnotationsFromFDFString(LegacyFDF);
    Assert(Lib.AnnotationCount = 1);
    Assert(Lib.GetAnnotTitle(1) = 'Alpha');
    // Znovu exportováno jako XFDF s tečkovými decimály: rect="10.500 20.250 40.750 60.125"
    Writeln(Lib.ExportAnnotationsToXFDFString);
  finally
    Lib.Free;
  end;
end;

Co by měl test importu anotací doopravdy kontrolovat?

Užitečný test importu se upíná na stav cílového dokumentu, nikdy jen na to, co importer říká o sobě. Nic v test suite nekontrolovalo AnnotationCount po FDF importu a návratová hodnota, jediný pohledávaný údaj, zůstala tím jediným číslem, které vada nechala v pořádku. Tři aserce by odhalily každou tuhle vadu: počet anotací na očekávané stránce, jedno pole přečtené zpět přes GetAnnotType nebo GetAnnotContentsEx a druhý export porovnaný bajt po bajtu s prvním. Stejná disciplína platí pro jakékoli API, které hromadně přepisuje strukturu dokumentu, včetně slučování duplicitních form fields: kontrolujte výsledný strom, ne vrácený součet. Metody anotací FDF a XFDF, včetně variant pro soubory i řetězce, sjíždějí v losLab PDF Library for Delphi and C++Builder, a v3.539.30 či novější je verze, na které komentáře přežijí cestu, v3.539.40 či novější zase ta, na které vrácený počet sedí s tím, co se přidalo