До 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
Значення, що повертається, потребувало уваги навіть після того виправлення. До 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, який тихо сигналізує невдачу на деформованому токені замість підняття винятку. Якщо будь-яке з чотирьох чисел прямокутника зазнає невдачі, всі чотири відкатуються до нуля замість того, щоб видати напівпрочитаний прямокутник
Чому 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 анотації — три десяткові, крапковий розділювач, без експоненти — і відкатується до обчисленого прямокутника, лише коли збереженого масиву немає чи в ньому не чотири числа
Регресний тест, який це фіксує, варто перейняти, бо він перевіряє документ і повторний експорт, а не значення, що повертає імпортер. Зверніть увагу на очікувану кількість 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 чи новіша — якщо повернута кількість мусить збігатися з доданим