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

Импорт 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, тогда как вызывающий всё равно увеличивает результат. Искатель ключа возвращал позицию сразу после /Subtype — то есть пробел перед значением. ReadName стартовал с этого пробела и останавливался на первом же whitespace-символе, то есть заканчивал, не прочитав ничего. AddAnnotationToPage отказывается строить аннотацию без subtype — в отдельности правильный защитный ход, — но была это процедура без возвращаемого значения, а Inc(Result) сидел снаружи. Каждый предохранитель поодиночке разумен; вместе они превратили «не работает ничего» в «работает всё». Починка заставляет ReadName пропускать пробелы, требовать начальный / PDF-name-объекта и останавливаться на любом разделителе, включая [, ( и ), так что и /Subtype/Text, и /Subtype /Text дают Text

ImportAnnotationsFromFDFString в PDFlibPas находила /Subtype, стартовала ReadName на пробеле после ключа и получала пустое имя, AddAnnotationToPage выходила из-за отсутствующего subtype, а вызывающий всё равно наращивал результат, отчитываясь о семи импортированных комментариях без единого добавленного в документ
Каждый предохранитель поодиночке разумен, вместе они превратили «не работает ничего» в «работает всё», поэтому возвращаемое значение никогда не должно быть единственным, что проверяет тест импорта

Возвращаемое значение требовало внимания и после той починки. Вплоть до 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;

Ещё три дефекта за первым

Починить один 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-объектов, а ключи словарей в PDF неупорядочены (§7.3.7), так что любой парсер FDF, предполагающий порядок ключей, ошибочен по построению — что бы ни породило файл

Отремонтированный импортёр сперва ограничивает каждую запись. FindDictEnd идёт от открывающей << до парной >>, отслеживая вложенные словари и пропуская тела literal-строк с их backslash-эскейпами, так что >> внутри комментария вроде (see section >> 4) не может закончить запись раньше времени. Каждый поиск ключа стартует с начала самой записи и ограничен её концом, что делает порядок ключей безразличным и не даёт одной аннотации занимать чужой /Page. Совпадение ключа принимает разделитель прямо после имени, потому что /Contents(Hi) так же валиден, как /Contents (Hi), а правило границы слова не даёт /Subj сцепиться с началом /Subtype и /T — с /Type. ReadNumber теперь берёт позицию как var-параметр, пропускает пробелы и [ и парсит через PLTryStrToFloatInvariant, мягко отказывая на кривом токене вместо исключения. Если любое из четырёх чисел прямоугольника не разобралось, все четыре откатываются в нули, а не выдают наполовину прочитанный прямоугольник

FindDictEnd в PDFlibPas теперь ограничивает каждую 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 в рисовальных координатах библиотеки — том самом space, которым управляет 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 аннотации — три знака, точка-разделитель, без экспоненты, — а регрессионный тест сравнивает второй экспорт с первым побайтово

Регрессионный тест, прибивающий это, стоит скопировать: он утверждает состояние документа и второй экспорт, а не возвращаемое значение импортёра. Заметьте ожидаемое число 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-потоки в этот маршрут не входят, а экспортёр пропускает аннотации Widget, потому что поля формы — вотчина form-data методов. Общая карта того, какие данные едут каким методом, — в обзоре обмена формами FDF, XFDF и XFA; если нужно осмотреть, что реально приехало, поиндексные читатели вроде GetAnnotType, GetAnnotTitle и GetAnnotContentsEx разобраны в статье об интроспекции outline, аннотаций и action

Как читать FDF и XFDF с запятыми в десятичных из старых экспортов?

Для FDF ответ однозначен: запятая — не разделитель в синтаксисе PDF, значит числовой токен ровно с одной запятой и без точки может быть только десятичной дробью с машины с comma-локалью. Прежние версии такое писали, например /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 и раньше на системе с comma-локалью писали rect="50,500 80,250 70,750 100,125" и opacity="0,600", а при чтении стандартного opacity="0.6" падали с EConvertError. С v3.539.29 оба направления инвариантны, а легаси-форму распознаёт XFDFNormalizeLegacyDecimals, только когда атрибут бьётся по whitespace ровно на ожидаемое число токенов (четыре для rect, по одному для opacity и width) и каждый токен имеет вид цифры-запятая-цифры. Стандартный rect никогда не совпадёт: это либо один токен с тремя запятыми, либо токены, кончающиеся запятой. dashes намеренно оставлен в покое: 4,2 может быть двумя длинами штриха или легаси-4.2, и ни одно правило их не различит

const
  // Ключи вне порядка экспортёра плюс запятые-десятичные из старого экспорта с comma-локалью
  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 и C++Builder; v3.539.30 и новее — версия для случая, когда комментарии обязаны пережить поездку, v3.539.40 и новее — когда возвращённое число обязано совпасть с добавленным