Технічна стаття

Імпорт 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 comments imported», задача стає зеленою, а у вихідному PDF немає жодного коментаря. Нічого не збурилося, нічого не попередило, а число виглядало правдоподібно, бо це була справжня кількість записів у файлі. Це найгірша форма бага: функція, єдиним сигналом успіху якої є лічильник, що обчислюється незалежно від роботи, про яку нібито звітує

Чому ImportAnnotationsFromFDFString звітувала успіх, не додавши нічого?

Імпортер читав кожен /Subtype як порожній рядок, а хелпер, що створює анотацію, робив ранній вихід на порожньому підтипі — викликач же усе одно нарощував результат. Шукач ключів повертав позицію одразу після /Subtype, а це пробіл перед значенням. ReadName стартував із того пробіла і зупинявся на першому пробільному символі, тож зупинявся, не прочитавши нічого. AddAnnotationToPage відмовляється будувати анотацію без підтипу — правильний оборонний вибір саме по собі, — але це була процедура без значення, що повертається, а Inc(Result) сидів зовні. Кожен захист розумний окремо; разом вони перетворили «нічого не працювало» на «все працювало». Виправлення змушує ReadName пропускати пробіли, вимагати початковий / об'єкта-імені PDF і зупинятися на будь-якому розділювачі, зокрема [, ( і ), тож /Subtype/Text і /Subtype /Text обидва дають Text

PDFlibPas ImportAnnotationsFromFDFString знаходила /Subtype, стартувала ReadName на пробілі після ключа, тож та повертала порожнє ім'я, AddAnnotationToPage виходила через відсутній підтип, а викликач усе одно нарощував результат, звітуючи сім імпортованих коментарів і не додавши жодного до документа
Кожен захист розумний окремо; разом вони перетворили «нічого не працювало» на «все працювало», тому значення, що повертається, ніколи не мусить бути єдиним, що перевіряє тест імпорту

Значення, що повертається, потребувало уваги навіть після того виправлення. До v3.539.39 ImportAnnotationsFromFDFString усе ще нарощувала результат за кожен коректний словник у масиві /Annots, включно із записами, чий 0-базований /Page був поза діапазоном чи в яких бракувало /Subtype, — і ті, й інші пропускаються. З PDFlibPas v3.539.40 ImportAnnotationsFromFDFString і ImportAnnotationsFromFDF повертають кількість фактично доданих анотацій, як і XFDF-імпорт: FDF-хелпер 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);   // на вибраній сторінці, разом із віджетами
  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;

Ще три дефекти за першим

Одне лише виправлення підтипу відкрило б ще три баги в тій самій функції, кожен із яких лишався невидимим лише тому, що жодна анотація так і не дійшла до сторінки. Перше: ReadNumber брав позицію як параметр-значення, тож читання чотирьох чисел /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, а ключі словників у PDF невпорядковані (§7.3.7), тож будь-який FDF-парсер, що припускає порядок ключів, неправильний за побудовою, який би інструмент не породжував файл

Виправлений імпортер спершу обмежує кожен запис. FindDictEnd проходить від відкривного << до парного >>, відстежуючи вкладені словники і пропускаючи тіла літеральних рядків разом із їхніми backslash-екрануваннями, тож >> усередині коментаря на кшталт (see section >> 4) не може закінчити запис раніше часу. Кожен пошук ключа тоді стартує з власного початку запису й обмежений його кінцем, що робить порядок ключів байдужим і не дає одній анотації позичити чужий /Page. Збіг ключа також приймає розділювач просто після імені, бо /Contents(Hi) так само коректний, як /Contents (Hi), тоді як правило межі слова не дає /Subj зчепитися з початком /Subtype, а /T — з /Type. ReadNumber тепер бере позицію як параметр var, пропускає пробіли та [ і розбирає через PLTryStrToFloatInvariant, який тихо сигналізує невдачу на деформованому токені замість підняття винятку. Якщо будь-яке з чотирьох чисел прямокутника зазнає невдачі, всі чотири відкатуються до нуля замість того, щоб видати напівпрочитаний прямокутник

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

Чому FDF-проходи туди-назад зсували кожну анотацію на її власну висоту?

Старий експортер писав прямокутник у неправильній координатній моделі. /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-прямокутник, тож верхня грань приземлялася там, де мав бути нижній лівий кут, і кожен прохід туди-назад зсував анотацію вгору на її власну висоту. Експортер тепер копіює власні числа /Rect анотації — три десяткові, крапковий розділювач, без експоненти — і відкатується до обчисленого прямокутника, лише коли збереженого масиву немає чи в ньому не чотири числа

PDFlibPas раніше серіалізувала FDF /Rect як left, top, width, height у координатах малювання, тож імпорт тих чотирьох чисел назад як llx lly urx ury приземляв верхню грань там, де мав бути нижній лівий кут, і зсував кожну анотацію вгору на її власну висоту за кожного проходу
Експортер тепер копіює власні числа /Rect анотації — три десяткові, крапковий розділювач, без експоненти — а регресний тест порівнює повторний експорт байт-у-байт із першим

Регресний тест, який це фіксує, варто перейняти, бо він перевіряє документ і повторний експорт, а не значення, що повертає імпортер. Зверніть увагу на очікувану кількість 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 streams не входять у цей маршрут, а експортер пропускає анотації Widget, бо поля форм належать методам form-data. Ширша мапа того, які дані їдуть яким методом, — в огляді обміну даними форм FDF, XFDF і XFA, а якщо треба інспектувати, що реально прибуло, читачі за індексом на кшталт GetAnnotType, GetAnnotTitle і GetAnnotContentsEx розібрані в інтроспекції закладок, анотацій і дій

Як читати 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 обидва напрямки інваріантні, а легаси-форму розпізнає XFDFNormalizeLegacyDecimals лише тоді, коли атрибут розбивається за пробілами точно на очікувану кількість токенів (чотири для rect, один для opacity і width) і кожен токен має форму цифри-кома-цифри. Стандартний rect ніколи не збігається: це або один токен із трьома комами, або токени, що закінчуються комою. dashes навмисно залишається недоторканим, бо 4,2 може бути двома довжинами штрихів чи легаси 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-імпорту, а значення, що повертається, — єдине число, на яке хтось дивився, — було якраз тим числом, який баг залишив недоторканим. Три перевірки зловили б кожен описаний тут дефект: кількість анотацій на очікуваній сторінці, одне поле, прочитане назад через GetAnnotType чи GetAnnotContentsEx, і повторний експорт, звірений байт-у-байт із першим. Та сама дисципліна стосується будь-якого API, що переписує структуру документа масово, включно з консолідацією полів, описаною в злитті дублікатів полів форм: перевіряйте дерево, що вийшло, а не повернуту суму. Методи FDF- і XFDF-анотацій з їхніми файловими та рядковими варіантами входять до losLab PDF Library for Delphi and C++Builder, і v3.539.30 чи новіша — версія для тих, у кого коментарі мусять вижити в дорозі, v3.539.40 чи новіша — якщо повернута кількість мусить збігатися з доданим