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