До 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
Возвращаемое значение требовало внимания и после той починки. Вплоть до 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, мягко отказывая на кривом токене вместо исключения. Если любое из четырёх чисел прямоугольника не разобралось, все четыре откатываются в нули, а не выдают наполовину прочитанный прямоугольник
Почему 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 аннотации — три десятичных знака, разделитель-точка, без экспоненты — и откатывается к вычисленному прямоугольнику, только если сохранённый массив отсутствует или не содержит четырёх чисел
Регрессионный тест, прибивающий это, стоит скопировать: он утверждает состояние документа и второй экспорт, а не возвращаемое значение импортёра. Заметьте ожидаемое число 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 и новее — когда возвращённое число обязано совпасть с добавленным