Техническа статия

Импорт на FDF анотации в Delphi: поправката на тихата нула

Преди v3.539.30 TPDFlib.ImportAnnotationsFromFDFString в losLab PDF Library връщаше броя на FDF записите за анотации, които е парснал, без да добави нито един в документа: всеки запис биваше броен, всеки запис биваше изхвърлян. От v3.539.30 FDF импортерът чете ключове в произволен ред, парсва /Rect коректно и независимо от локала, а съответният експортер записва истинския /Rect на анотацията, така че експорт, импорт и втори експорт дават байт-идентичен FDF. Останалата част от бележката обяснява как една грешна начална позиция създаде съвършен тих отказ, кои още три дефекта се криеха зад него и как сами да проверите импорт, вместо да вярвате на връщаната стойност

Сценарият е обикновен. Ревюор маркира договор, коментарите пътуват като FDF файл (Acrobat го нарича Export Comments), а вашият Delphi сервиз ги слива в чисто копие с ImportAnnotationsFromFDF. Извикването връща 7, логът казва „7 коментара импортирани", задачата светва зелено, а изходният PDF няма нито един коментар. Нищо не вдигна грешка, нищо не предупреди, а числото изглеждаше правдоподобно, защото беше истинският брой записи във файла. Това е най-лошият вид бъг: функция, чийто единствен сигнал за успех е брояч, смятан независимо от работата, за която уж докладва

Защо ImportAnnotationsFromFDFString докладва успех, а не добави нищо?

Импортерът четеше всеки /Subtype като празен низ, а helper-ът, създаващ анотацията, излизаше рано при празен subtype, докато извикващият въпреки това увеличаваше резултата. Търсачът на ключове връщаше позицията веднага след /Subtype, тоест whitespace-а пред стойността. ReadName започваше от този интервал и спираше на първия whitespace символ, така че спираше преди да е прочел нещо. AddAnnotationToPage отказва да строи анотация без subtype, което поотделно е правилният защитен избор, но беше процедура без връщана стойност, а Inc(Result) седеше извън нея. Всяка защита беше разумна сама по себе си; заедно превърнаха „нищо не работи" в „всичко работи". Поправката кара ReadName да прескача whitespace, да изисква водещия / на PDF name обект и да спира на всеки разделител, включително [, ( и ), така че /Subtype/Text и /Subtype /Text дават еднакво Text

PDFlibPas ImportAnnotationsFromFDFString намира /Subtype, стартира ReadName върху whitespace-а след ключа, така че връща празно име, AddAnnotationToPage излиза при липсващ subtype, а извикващият увеличава резултата въпреки това, докладвайки седем импортирани коментара, без да добави нито един в документа
Всяка защита беше разумна поотделно; заедно превърнаха нищо не работи във всичко работи, затова връщаната стойност никога не бива да е единственото, което импорт тест проверява

Връщаната стойност заслужаваше внимание и след тази поправка. До v3.539.39 ImportAnnotationsFromFDFString продължаваше да увеличава резултата си за всеки добре оформен речник в масива /Annots, включително записи, чийто 0-базиран /Page е извън обхвата или чийто /Subtype липсва — и двете се прескачат. От PDFlibPas v3.539.40 ImportAnnotationsFromFDFString и ImportAnnotationsFromFDF връщат броя на действително добавените анотации, както прави XFDF импортът: FDF helper-ът AddAnnotationToPage вече връща Boolean, а броячът мърда само при успех. Меркането на документа си остава по-силната проверка, защото важи и на по-стари версии, затова скицата по-долу сравнява AnnotationCount на всяка страница преди и след импорта

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);   // на избраната страница, включително widgets
  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   // равни откакто 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;

Още три дефекта зад първия

Поправката само на subtype-а би разкрила още три бъга в същата функция, всеки от които беше невидим само защото нито една анотация не стигаше до страница. Първо, ReadNumber приемаше позицията си като value параметър, така че четенето на четирите числа на /Rect подред четеше едно и също място четири пъти, а и не прескачаше отварящата [, така че на практика не четеше нищо. Второ, FindKey споделяше един движещ се напред курсор между всички търсения. Експортерът записва /Subtype, /Rect, /Page, /Contents, /T, /Subj, но импортерът търсеше в реда /Subtype, /Contents, /T, /Subj, /Page, /Rect; щом курсорът беше минал /Contents, търсенето на /Page и /Rect изтичаше отвъд текущия запис и или не намираше нищо, или уцелваше ключовете на следващата анотация. Библиотеката не можеше да прочете собствения си изход. Трето, числата минаваха през PLStrToFloat, който следва системния десетичен разделител. ISO 32000-1 §12.7.7 дефинира FDF като PDF object синтаксис, а ключовете на речниците в PDF са неподредени (§7.3.7), така че всеки FDF парсер, предполагащ ред на ключовете, е грешен по конструкция, без значение кой инструмент е създал файла

Поправеният импортер първо огражда всеки запис. FindDictEnd върви от отварящото << до съответстващото му >>, следи вложени речници и прескача телата на literal низове с техните backslash екранирания, така че >> вътре в коментар като (see section >> 4) не може да прекрати записа рано. Всяко търсене на ключ после започва от собствения старт на записа и е ограничено до края му, което прави редът на ключовете без значение и спира една анотация да позаема /Page на друга. Съвпадението на ключ приема и разделител директно след името, защото /Contents(Hi) е толкова валидно колкото /Contents (Hi), докато правилото за граница на дума пази /Subj да не съвпадне с началото на /Subtype, а /T — с /Type. ReadNumber вече приема позицията си като var параметър, прескача whitespace и [, и парсва с PLTryStrToFloatInvariant, който се проваля меко на malformed токен вместо да вдига грешка. Ако някое от четирите числа на правоъгълника се провали, всичките четири се връщат на нула, вместо да излезе наполовина прочетен правоъгълник

PDFlibPas FindDictEnd вече огражда всяка FDF анотация от нейното отварящо << до съответстващото >>, така че всяко търсене на ключ почва от началото на записа и спира на края му, а ReadNumber приема var позиция, прескача скобата и парсва с PLTryStrToFloatInvariant
Споделеният курсор не можеше да прочете собствения експорт на библиотеката: щом мина /Contents, търсенията на /Page и /Rect налитаха върху ключовете на следващата анотация, затова редът на ключовете вече няма право да има значение

Защо FDF round trip местеше всяка анотация с нейната собствена височина?

Старият експортер записваше правоъгълника в грешния координатен модел. /Rect на анотацията е [llx lly urx ury] в default user space (ISO 32000-1 §12.5.2, правоъгълниците са дефинирани в §7.9.5), а FDF носи същия масив. ExportAnnotationsToFDFString обаче извикваше GetAnnotRectEx, който докладва Left, Top, Width и Height в координатите за чертане на библиотеката — пространството, което SetOrigin управлява — и ги сериализираше като [L T L+W T+H]. Импортерът, щом започна да работи, записваше тези четири стойности обратно дословно като PDF правоъгълник, така че горният ръб попадаше там, където принадлежи долният ляв ъгъл, и всеки round trip местеше анотацията нагоре с нейната собствена височина. Експортерът вече копира собствените числа на /Rect на анотацията — три десетични знака, точка като разделител, без експонента — и се връща към сметнатия правоъгълник само когато записаният масив липсва или не е четири числа

PDFlibPas сериализираше FDF /Rect като left, top, width, height в координатите за чертане, така че импортирането на тези четири числа обратно като llx lly urx ury слагаше горния ръб там, където принадлежи долният ляв ъгъл, и местеше всяка анотация нагоре с нейната собствена височина при всеки round trip
Експортерът вече копира собствените числа на /Rect на анотацията — три десетични знака, точка като разделител, без експонента — а регресионният тест сравнява втори експорт байт по байт с първия

Регресионният тест, който закова това, си заслужава да се копира, защото assertion-ите са върху документа и върху втори експорт, не върху връщаната стойност на импортера. Обърнете внимание на очаквания брой 2: AddNoteAnnotation създава Text анотация плюс нейния Popup, и двете пътуват. Тестът също пуска експорта и импорта при десетичен разделител запетая — там живее другата половина от тази история

var
  Source, Target: TPDFlib;
  FDF: AnsiString;
  OldSep: Char;
begin
  Source := TPDFlib.Create;
  Target := TPDFlib.Create;
  try
    Source.NewPages(1);                     // вече две страници
    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 := ',';   // симулирай немски или френски десктоп
    try
      FDF := Source.ExportAnnotationsToFDFString;   // пак записва /Rect [50.5 ...
      Target.ImportAnnotationsFromFDFString(FDF);
    finally
      FormatSettings.DecimalSeparator := OldSep;
    end;

    Target.SelectPage(2);
    Assert(Target.AnnotationCount = 2);           // бележката и popup-ът ѝ
    Assert(Target.GetAnnotType(1) = 'Text');
    Assert(Target.ExportAnnotationsToFDFString = Source.ExportAnnotationsToFDFString);
  finally
    Target.Free;
    Source.Free;
  end;
end;

Бъдете ясни какво носи FDF пътят. Импортерът преизгражда всеки запис като речник с /Type, /Subtype, /Rect, /Contents, /T и /Subj; цвят, флагове, стил на рамка, popup връзки и appearance stream-и не са част от този маршрут, а експортерът прескача Widget анотациите, защото форм полетата принадлежат на методите за form данни. По-широката карта кое пътува през кой метод е в прегледа на размяната на FDF, XFDF и XFA form данни, а ако трябва да огледате какво действително е пристигнало, четците по индекс като GetAnnotType, GetAnnotTitle и GetAnnotContentsEx са покрити в introspection на outline, анотации и action-и

Как четете FDF и XFDF файлове с десетична запетая от по-стари експорти?

За FDF отговорът е еднозначен: запетаята не е разделител в PDF синтаксиса, така че числов токен с точно една запетая и без точка може да е само десетично число, написано на машина със запетая в локала. По-старите версии наистина записваха такива файлове, например /Rect [10,500 20,250 40,750 60,125], а новият ReadNumber превръща тази единствена запетая в точка преди парсване. Токен с две запетаи, или запетая и точка, се отхвърля, вместо да се отгатва. Четецът не консумира и експоненциална нотация, което пасва на ISO 32000-1 §7.3.3: PDF числата никога не я ползват

XFDF е по-труден, защото в XML атрибути запетаята е разделителят. Стандартният XFDF (ISO 19444-1) записва rect="50.5,80.25,70.75,100.125" и dashes="4,2", докато v3.539.28 и по-стари, на система със запетая в локала, записваха rect="50,500 80,250 70,750 100,125" и opacity="0,600", а и се проваляха с EConvertError при четене на стандартно opacity="0.6". От v3.539.29 двете посоки са инвариантни, а legacy формата се разпознава от XFDFNormalizeLegacyDecimals само когато атрибутът се раздели по whitespace на точно очаквания брой токени (четири за rect, един за opacity и width) и всеки токен е от вида цифри-запетая-цифри. Стандартен rect никога не съвпада: той е или един токен с три запетаи, или токени, свършващи на запетая. dashes е оставен нарочно настрана, защото 4,2 може да са две дължини на тирета или legacy 4.2, и никакво правило не може да ги различи

const
  // Ключове в друг ред от този на експортера, плюс десетични със запетая от по-стар запис
  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;               // нов документ има една страница
  try
    Lib.ImportAnnotationsFromFDFString(LegacyFDF);
    Assert(Lib.AnnotationCount = 1);
    Assert(Lib.GetAnnotTitle(1) = 'Alpha');
    // Преекспортирано като XFDF с десетична точка: rect="10.500 20.250 40.750 60.125"
    Writeln(Lib.ExportAnnotationsToXFDFString);
  finally
    Lib.Free;
  end;
end;

Какво всъщност трябва да проверява тест за импорт на анотации?

Полезен импорт тест проверява състоянието на целевия документ, никога само това, което импортерът казва за себе си. Нищо в тестовия набор не проверяваше AnnotationCount след FDF импорт, а връщаната стойност — единственото число, което някой гледаше — беше точно числото, което бъгът остави непокътнат. Три assertion-а щяха да хванат всеки описан тук дефект: броят анотации на очакваната страница, едно поле, прочетено обратно чрез GetAnnotType или GetAnnotContentsEx, и втори експорт, сравнен байт по байт с първия. Същата дисциплина важи за всеки API, който пренаписва структурата на документа едро, включително консолидацията на полета, описана в сливането на дублирани form полета: проверявайте полученото дърво, не върнат общ брой. Методите за FDF и XFDF анотации, с файловите и стринговите им варианти, идват с losLab PDF Library for Delphi и C++Builder, а v3.539.30 или по-нова е версията за пускане, ако коментарите трябва да оцелеят при пътуването, и v3.539.40 или по-нова, ако връщаният брой трябва да съвпада с добавеното