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

Flatten PDF-аннотаций без /AP stream в Delphi

HotPDF v2.743.0 выполняет flatten для PDF-аннотаций без appearance stream /AP, а не молча пропускает их. FlattenLoadedAnnotations теперь направляет widget без appearance через EnsureLoadedFieldAppearanceStream, а для markup без appearance строит Form XObject из собственных свойств аннотации, поэтому значения, введённые в форму с /NeedAppearances, попадают в контент страницы вместо исчезновения во время flatten. Ошибка, заставившая менять код, выглядит как отсутствие действия. Заказчик присылает заполненную анкету, распечатанную в PDF из браузера. Вы загружаете её в HotPDF, вызываете FlattenLoadedAnnotations, получаете 0, сохраняете и передаёте документ с пустыми полями там, где заявитель ввёл имя и сумму. Ничего не вызвано, ничего не записано в журнал. Значения всё время были в файле, в записи /V каждого поля, а flatten pass проходил мимо, потому что ни один из этих widget не содержал appearance stream для встраивания

Почему flatten формы, распечатанной браузером, теряет введённые значения?

Потому что форма /NeedAppearances хранит значение, но не хранит его изображение. ISO 32000-1 12.7.2 позволяет интерактивной форме установить /NeedAppearances true в словаре AcroForm, что говорит viewer построить визуальную поверхность каждого поля при открытии из /V, /DA и /Q. Producers, которые дёшево генерируют формы — браузерные пути печати, серверные filler-ы, некоторые front end для сканирования, — пользуются этой возможностью и вообще не записывают /AP. Flattening по алгоритму appearance из ISO 32000-1 12.5.5 — это задача транскрипции: взять normal appearance stream аннотации, отобразить его /BBox на /Rect, вызвать его из content stream страницы оператором Do, затем удалить аннотацию. Если исходного stream нет, транскрибировать нечего. Исходная реализация HotPDF, начиная с v2.386.0, трактовала это как «skip», что по отдельности выглядит защитимо, а в совокупности губительно: документы, которым flatten нужен сильнее всего, с наименьшей вероятностью содержат appearance. Та же дыра поглощала markup — Highlight из review-инструмента, Square из redline-процесса, Ink-подпись, — когда producer полагался на viewer и ожидал, что тот всё нарисует

Где HotPDF подключает синтез к FlattenLoadedAnnotations

Точка подключения намеренно выбрана поздней: после неудачи поиска appearance, а не до него. FlattenLoadedAnnotations по-прежнему сначала запрашивает normal appearance через GetLoadedAnnotationAppearanceStream, и аннотация, у которой он уже есть, встраивается ровно так же, как в v2.386.0. Только результат nil на аннотации с недегенеративным /Rect и без hidden flag запускает synthesis path. Порядок важен: автор документа, потративший усилия на запись /AP, получает собственные байты, а не реконструкцию от HotPDF

NStrm:= GetLoadedAnnotationAppearanceStream(Indices[PgI], AnI, aakNormal);
if (NStrm= nil) and (RR> RL) and (RT> RB) and ((FlagsValue and 2)= 0) then
begin
  if Subtype= 'Widget' then
  begin
    FieldIdx:= GetLoadedFormFieldIndexForAnnotation(Indices[PgI], AnI, WidgetIdx);
    if FieldIdx>= 0 then
      EnsureLoadedFieldAppearanceStream(FieldIdx);
    // запросить ещё раз: generator прикрепил /AP /N к widget
    NStrm:= GetLoadedAnnotationAppearanceStream(Indices[PgI], AnI, aakNormal);
  end
  else
    NStrm:= SynthesizeMarkupAppearance(AnnotDict, Subtype, RL, RB, RR, RT);
end;

После этого две семьи аннотаций расходятся. Widget разрешается обратно к владеющему field через GetLoadedFormFieldIndexForAnnotation и передаётся в EnsureLoadedFieldAppearanceStream — генератор appearance поля, который присутствует в этой PDF-библиотеке для Delphi с v2.328.0. Повторное использование его вместо написания второго field renderer — весь смысл: он уже покрывает Type0 fonts, перенос строк, quadding, состояния /AS checkbox и radio и rotation /MK, то есть ту же механику, которая лежит за добавлением полей AcroForm в уже загруженный PDF. Для вызывающего кода ничего не меняется: тот же однострочный flatten теперь возвращает ненулевое количество в документах, которые раньше возвращали ноль

Doc:= THotPDF.Create(nil);
try
  Doc.LoadFromFile('needappearances-form.pdf');
  // v2.743.0: AP-less widgets и markup синтезируются, затем встраиваются
  Flattened:= Doc.FlattenLoadedAnnotations;          // all pages, all subtypes
  // Flattened:= Doc.FlattenLoadedAnnotations('1-3', 'Highlight');
  if Flattened= 0 then
    raise Exception.Create('nothing was flattened');
  Doc.SaveLoadedDocument('flattened.pdf');
finally
  Doc.Free;
end;

Почему QuadPoints и InkList оказываются не там?

Потому что эти координаты находятся в page user space, а синтезированный appearance stream рисует в собственном пространстве /BBox, и эти две точки отсчёта не совпадают. ISO 32000-1 Table 176 задаёт /QuadPoints для text markup annotations в default user space, а Table 174 делает то же для конечных точек /L line annotation; /InkList следует тому же соглашению. HotPDF задаёт синтезированной форме /BBox равный [0 0 W H], начало которого находится в левом нижнем углу /Rect. Поэтому каждую точку из /QuadPoints, /L или /InkList перед записью в content stream нужно сдвинуть на отрицательные значения нижнего левого угла /Rect. Ошибитесь — и highlight на строке, находящейся на 700 points выше страницы, нарисуется ещё на 700 points выше собственной рамки, то есть на практике не появится нигде. Исправление — одно вычитание на координату, и оно сочетается с cm, который bake выводит после этого: матрица возвращает /BBox на /Rect, поэтому два шага взаимно компенсируются и дают правильную абсолютную геометрию

// Конечные точки /L находятся в page user space (ISO 32000-1 Table 174); начало
// BBox расположено в левом нижнем углу /Rect, поэтому нужен сдвиг на -(RL, RB)
X1:= ArrNum(LA, 0, 0)- RL;
Y1:= ArrNum(LA, 1, 0)- RB;
X2:= ArrNum(LA, 2, 0)- RL;
Y2:= ArrNum(LA, 3, 0)- RB;
StrokeOp:= ColorOp(DArr('C'), true);
if StrokeOp= '' then
  StrokeOp:= '0 G';
Result:= _FloatToStrR(BW)+ ' w '#10+ StrokeOp+ #10+
  _FloatToStrR(X1)+ ' '+ _FloatToStrR(Y1)+ ' m '+
  _FloatToStrR(X2)+ ' '+ _FloatToStrR(Y2)+ ' l S'#10;

Что на самом деле рисует синтезированный markup appearance

Markup synthesizer читает только словарь аннотации, что делает результат предсказуемым и честно показывает, чего он знать не может. FreeText и Stamp рисуют /Contents, используя шрифт и цвет, разобранные из /DA, выравнивая по /Q и добавляя отступ 2 pt. Square и Circle рисуют контур re или четырёхарочную кривую Bezier, обведённую цветом /C и залитую /IC, если он есть, с шириной из /BS /W. Line и Ink обводят свои вершины. Highlight заполняет каждый quad, а Underline, StrikeOut и Squiggly обводят линию по нижней границе quad, по его середине или зигзагом в одну точку. Значение /CA ниже 1 превращается в ExtGState с записью ca, на которую ссылаются как на /GSA gs в начале stream

Кодировка текста определяется записью /DR /Font AcroForm, названной через /DA. Если /Subtype этого шрифта равен Type0, HotPDF записывает строку как UTF-16BE hex literal с marker порядка байт FEFF; в противном случае пишет escaped literal string, экранируя скобки и обратные слеши, а байты выше 126 записывая в octal. Оператор Tf из /DA выдаётся перед BT, что допустимо, поскольку text state сохраняется между границами text object, и избавляет от разбора строки /DA. Два ограничения стоит обозначить прямо. Ширина строки для wrapping и quadding оценивается эвристикой half-em / full-em, а не настоящими метриками шрифта, поэтому выравнивание у пропорционального шрифта близко, но не точно. А subtype, для которого нечего синтезировать — Popup, Link, Stamp с одним только именем icon, — возвращает nil и остаётся нетронутым, как и раньше

Временная замена /Annots, которая наказывает за полезную очистку

FlattenOneWidget, per-widget path, используемый FlattenLoadedFormFields, — это aliasing trap, который должен учитывать любое изменение внутри общего flatten loop. Он временно заменяет значение /Annots страницы массивом из одного элемента, чтобы общий flatten pass работал с одним widget, а затем в блоке finally восстанавливает исходный указатель PHPDFDictionaryItem. Восстановление записывает значение обратно в слот словаря, захваченный до вызова

DictItem:= PHPDFDictionaryItem(PageObj.Items.Items[AnnotsIndex]);
Item:= DictItem^.Value;
TemporaryAnnots:= THPDFArrayObject.Create(nil);
TemporaryAnnots.AddObject(Target);
DictItem^.Value:= TemporaryAnnots;
try
  Result:= FlattenLoadedAnnotations(IntToStr(PageIndex+ 1), 'Widget')= 1;
finally
  DictItem^.Value:= Item;   // недействителен, если внутренний цикл освободил этот item
  TemporaryAnnots.Free;
end;

Добавьте в общий внутренний цикл аккуратно выглядящую уборку — DeleteValue('Annots'), когда массив опустеет, чтобы сохранённая страница не несла бесполезный пустой массив, — и этот вызов освободит тот самый dictionary item, на который указывает DictItem. Затем finally запишет по висячему указателю, и процесс завершится с «Invalid pointer operation». Два существующих теста сразу это поймали, и только поэтому данный случай остался сноской, а не обращением в поддержку. Правило обобщается: прежде чем добавлять cleanup в общий цикл, проверьте у вызывающих сторон контракты alias или swap. Оставшийся пустой массив /Annots — косметическая помеха, и не стоит менять гарантию времени жизни указателя на такую чистоту

Что остаётся невстроенным и сколько стоит flattening

Скрытые аннотации намеренно исключаются. Аннотация, у которой в целом числе /F установлен бит позиции 2, считается скрытой согласно ISO 32000-1 12.5.3, и когда у неё ещё нет /AP, очень хочется синтезировать appearance и встроить её как остальные. Это была бы ошибка с последствиями для безопасности: встраивание невидимой заметки в контент страницы сделало бы её видимой для каждого, кто откроет файл. HotPDF оставляет такие аннотации ровно на месте и не учитывает их в возвращаемом значении. Так же ясно объясняйте пользователям цену тех аннотаций, которые действительно встраиваются. Flattening необратим: аннотация удаляется из массива страницы /Annots, а её визуальное представление становится контентом страницы, поэтому больше нельзя редактировать значение поля, вести thread комментариев, переключать состояние /AS или восстановить структурированные данные без исходного файла. Flatten-ьте копию, храните оригинал и обращайтесь к копии только тогда, когда документ перестаёт быть формой и становится записью. Если проблема связана с XFA, а не с отсутствием appearance, начните с отдельного пути flattening XFA в AcroForm в HotPDF, а если форма ещё строится, заметки о подключении действий и валидации полей AcroForm описывают сторону записи

Есть одна оговорка о проверке, которая иначе отнимет целый день. ExtractLoadedPageGlyphs не спускается в Form XObjects, а встроенный appearance находится внутри одного из них — в content stream страницы остаётся только последовательность q ... cm /FlatAn<n> Do Q. Поэтому извлечение glyph на flattened page ничего не сообщает, и это правильное поведение, а не потерянный bake. Проверяйте либо на уровне байтов, ищите имя ресурса /FlatAn, вызов Do и /Subtype /Form, либо через rendering pipeline, который раскрывает XObjects

Flattening аннотаций выглядит как три строки транскрипции, пока не сталкиваешься с документами, которые реально генерируют пользователи. Если вы работаете с заполненными формами, review markup или архивным результатом в Delphi или C++Builder, стоит заранее прочитать, как PDF-компонент HotPDF для Delphi обрабатывает сторону загруженных AcroForm и аннотаций, прежде чем строить поверх него собственный appearance generator