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