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

Headless-рендерер HotPDF и наследование полей формы

PDF-форма со значениями полей, но без потоков внешнего вида может выйти из задачи рендеринга на Linux пустыми рамками, не тем шрифтом или выравниванием, либо EHPDFHeadlessRenderError с текстом «Widget field requires a name field type». Headless-рендерер HotPDF рисует временные внешние виды для текстовых, кнопочных и выборочных виджетов, наследуя /FT, /V, /DA и /Q по дереву полей

Где виджет хранит настройки поля

Большинство неприятностей начинается с разделения поля и его виджетов. Словарь поля держит логическую часть элемента управления формы: тип, значение, флаги и внешний вид по умолчанию. Аннотация виджета держит то, что нужно странице: /Rect, рамку, указатель на свою страницу. Поле с одним виджетом обычно слито в единый словарь. Когда одно и то же значение должно появиться на нескольких страницах, виджеты становятся потомками поля и часто не несут ничего, кроме геометрии и ссылки /Parent. ISO 32000-1 §12.7.3.1 делает /FT, /Ff, /V и /DV наследуемыми по этой цепочке (таблица 220), а §12.7.3.3 добавляет к наследуемому набору из таблицы 222 всего два атрибута переменного текста: /DA, который обязателен, но может прийти от предка, и /Q. Словарь интерактивной формы (таблица 218) поставляет общие для документа значения по умолчанию для /DA и /Q, плюс /DR — словарь ресурсов по умолчанию, в котором ищутся имена шрифтов из /DA. /DR в таблице 222 не входит: он принадлежит форме как целому. Рендерер, читающий только словарь виджета, не найдёт в файле ниже ни типа, ни значения, ни /DA и откатится к собственному шрифту по умолчанию

1 0 obj  % каталог
<< /Type /Catalog /Pages 2 0 R
   /AcroForm << /Fields [10 0 R] /DA (/Helv 0 Tf 0 g) /Q 0
                /DR << /Font << /Helv 20 0 R /Cour 21 0 R >> >> >> >>
endobj
10 0 obj  % терминальное поле: тип, значение и внешний вид по умолчанию живут здесь
<< /FT /Tx /T (invoice_no) /V (INV-2026-0417)
   /DA (/Cour 10 Tf 0 0 0.6 rg) /Q 2
   /Kids [11 0 R 12 0 R] >>
endobj
11 0 obj  % виджет на странице 1: только геометрия, углы даны начиная с правого верхнего
<< /Type /Annot /Subtype /Widget /Parent 10 0 R
   /Rect [300 720 160 700] /F 4 /P 3 0 R >>
endobj
12 0 obj  % виджет на странице 2: только геометрия
<< /Type /Annot /Subtype /Widget /Parent 10 0 R
   /Rect [160 60 300 80] /F 4 /P 4 0 R >>
endobj

HotPDF ищет каждую запись поля на виджете, а не найдя, поднимается по /Parent на один словарь за раз; побеждает ближайшее значение, и только /DA и /Q откатываются к словарю AcroForm, когда цепочка исчерпана. Ожидаемо для файла выше: оба виджета показывают INV-2026-0417 с выравниванием по правому краю, 10-пунктовым Courier, тёмно-синим, а общесловный /Helv 0 Tf никогда не вступает в игру, потому что объект 10 имеет собственную /DA. Когда поле выходит 12-пунктовым чёрным Helvetica, очевидный подозреваемый — шрифт Courier, отсутствующий на машине Linux. Размер выдаёт с головой: именно это рисует HotPDF, когда /DA не нашлась ни на виджете, ни на каком-либо предке, ни в словаре AcroForm. Так что выгрузите виджет, вручную пройдите его /Parent и найдите, куда производитель положил /DA — например, на словарь поля, на который виджет никогда не ссылается назад

Граф объектов AcroForm для headless-рендеринга HotPDF в Delphi: виджеты с одной геометрией поднимаются по /Parent к полю, держащему /FT, /V, /DA и /Q
побеждает ближайшее значение вдоль цепочки /Parent, и только /DA и /Q откатываются к словарю AcroForm, когда цепочка исчерпана

Имена шрифтов из /DA разрешаются по /DR на виджете или родительском поле, затем по /DR AcroForm и по шрифтовым ресурсам страницы, в этом порядке. /DR на уровне поля вне таблицы 222, но он однозначен, когда присутствует, поэтому HotPDF его принимает. Подъём ограничен: цикл в дереве останавливается с «Widget field inheritance contains a cycle», /Parent, не являющийся словарём, — с «Widget Parent requires a dictionary», а цепочка глубже MaxDepth в опциях рендеринга (32 по умолчанию) — с «Widget field inheritance depth budget exceeded». VCL-компонент применяет то же наследование, когда читает унаследованные значения AcroForm и сбрасывает поля

Как headless-рендерер решает, что рисовать?

HPDFRenderHeadlessPagePNG и его файловый вариант запускают резервный синтез только для видимого виджета, у которого /AP отсутствует или равен null; ISO 32000-1 §7.3.7 говорит, что null-значение словаря — то же, что отсутствующая запись, и рендерер обращается с /AP null именно так. Присутствующий нормальный внешний вид всегда побеждает, что бы ни говорил /NeedAppearances, поэтому устаревший внешний вид от раннего заполнения продолжает показываться, пока что-то не перегенерирует его. Загруженный документ не изменяется никогда. Что будет нарисовано, зависит от разрешённых типа поля и флагов:

ПолеСинтезированный внешний вид
Однострочный текст, /FT /TxСтрока /V, выложенная с разрешёнными /DA и /Q внутри /Rect
Флажок, /FT /BtnПрямоугольная рамка с радиусами углов из /Border, заливкой /MK /BG и обведённой векторной галочкой во включённом состоянии
Переключатель, Btn с битом 16Круглая рамка с залитой точкой во включённом состоянии
Кнопка, Btn с битом 17Прямоугольная рамка с центрированной подписью /MK /CA
Комбинированный список, /FT /Ch с битом 18Отображаемый текст выбранной записи /Opt в виде центрированной подписи
Список, Ch с очищенным битом 18Строка на опцию начиная с /TI, с подсвечивающей полосой за каждой выбранной строкой

Галочка и точка переключателя — векторные пути, а не глифы ZapfDingbats, как рисуют их многие генераторы, потому что в тощем контейнере Linux может не оказаться установленного ZapfDingbats. Лица кнопки и комбинированного списка совпадают с тем, что для того же поля рисует VCL-генератор внешних видов HotPDF, поэтому форма выглядит одинаково, с какой стороны ни вышло изображение. Подпись берёт шрифт, размер и цвет из /DA; поскольку компоновка подписи не подгоняется автоматически, размер 0 в /DA становится 70 процентами высоты бокса с потолком в 12 пунктов. Состояние флажка и переключателя берётся из собственного /AS виджета с откатом к унаследованному /V, и любое состояние помимо Off рисует отметку. Давайте каждому потомку-переключателю собственный /AS: /V родителя называет выбранное состояние для всей группы и не отличит одного потомка от другого

Схема решения в headless-рендерере HotPDF для Delphi: присутствующий /AP всегда побеждает, иначе разрешённый тип поля выбирает синтезированное лицо
временные внешние виды рисуются только когда /AP отсутствует или равен null, а сам загруженный документ никогда не изменяется
uses
  SysUtils, HPDFHeadlessDocument, HPDFHeadlessCairoDevice;

function RenderFormPage(const FileName, PngName: string; PageIndex: Integer;
  out Diagnostic: string): Boolean;
var
  Doc: THPDFHeadlessDocument;
  Options: THPDFHeadlessRenderOptions;
  Info: THPDFHeadlessRenderResult;
begin
  Result := False;
  Diagnostic := '';
  Doc := THPDFHeadlessDocument.Create;
  try
    Doc.LoadFromFile(FileName);
    Options := THPDFHeadlessRenderOptions.Default;  // RenderAnnotations = True, MaxDepth = 32
    Options.Usage := hruPrint;  // как печать в просмотрщике: виджеты без флага Print пропускаются
    try
      // PageIndex отсчитывается с нуля
      Info := HPDFRenderHeadlessPagePNGFile(Doc.GetRenderAccess(PageIndex),
        PngName, Options);
      Diagnostic := Format('%d appearances drawn', [Info.Appearances]);
      Result := True;
    except
      on E: EHPDFHeadlessRenderError do
        // например 'Widget field requires a name field type'; для заполненной формы не
        // перезапускайте с RenderAnnotations := False — он выбросит все виджеты и их значения
        Diagnostic := E.Message;
    end;
  finally
    Doc.Free;
  end;
end;

Резервный путь текстовых полей рисует однострочные поля, держащие строковое значение. Для любой более богатой текстовой компоновки генерируйте внешний вид в момент заполнения на стороне Windows, где EnsureLoadedFieldAppearanceStream строит недостающий поток /AP до того, как файл будет сведён или отправлен; тогда задача Linux получает настоящие потоки внешнего вида, а headless-рендерер называет любое встреченное поле, которому такой всё ещё нужен. Но судите о выводе Linux по рендереру Linux. RenderLoadedPageToBitmap рисует через бэкенд Windows GDI, который никогда не запускает этот резервный путь, поэтому страница, выглядящая правильно там, не скажет ничего о том, что устройство Cairo выдаст из того же файла

Биты флагов полей нумеруются с единицы

ISO 32000-1 §12.7.3.1 утверждает, что позиции битов в слове /Ff идут с 1 на младшем конце до 32, и таблицы 226, 228 и 230 пользуются этой нумерацией. В Delphi бит N — значит 1 shl (N - 1). Сдвиньте на сам номер бита, и каждая проверка переедет на одну позицию вверх: 1 shl 16, написанный для «переключателя, бит 16», на деле проверяет флаг кнопки, а 1 shl 18, написанный для «комбинированного списка, бит 18», проверяет Edit. Обе неверные маски — настоящие флаги. Код компилируется, работает и раскладывает поля не по тем видам

Флаги полей PDF нумеруют биты с единицы в Delphi: линейка показывает, почему переключателю достаётся 1 shl 15, а 1 shl 16 достигает кнопки
бит N слова /Ff берёт маску Delphi 1 shl N минус один, и каждая сместившаяся на единицу маска — настоящий флаг соседнего бита
ФлагТаблицаБитМаска Delphi
Multiline228 (текст)131 shl 12 = 4096
Password228 (текст)141 shl 13 = 8192
Comb228 (текст)251 shl 24 = 16777216
RichText228 (текст)261 shl 25 = 33554432
Radio226 (кнопка)161 shl 15 = 32768
Pushbutton226 (кнопка)171 shl 16 = 65536
Combo230 (выбор)181 shl 17 = 131072
uses
  Classes, SysUtils, HPDFHeadlessDocument;

const
  // ISO 32000-1 нумерует биты Ff с 1 (младший конец), поэтому бит N — 1 shl (N - 1)
  TxMultiline   = 1 shl 12;  // бит 13
  TxPassword    = 1 shl 13;  // бит 14
  TxFileSelect  = 1 shl 20;  // бит 21
  TxDoNotScroll = 1 shl 23;  // бит 24
  TxComb        = 1 shl 24;  // бит 25
  TxRichText    = 1 shl 25;  // бит 26
  TxProducerLayout = TxMultiline or TxPassword or TxFileSelect or
    TxDoNotScroll or TxComb or TxRichText;

procedure ListTextFieldsForProducerAppearance(const FileName: string;
  Report: TStrings);
var
  Doc: THPDFHeadlessDocument;
  Field: THPDFHeadlessFormField;
  I: Integer;
begin
  Doc := THPDFHeadlessDocument.Create;
  try
    Doc.LoadFromFile(FileName);
    for I := 0 to Doc.FormFieldCount - 1 do
    begin
      // FieldType, Flags и MaxLength приходят уже унаследованными от предков
      Field := Doc.GetFormField(I);
      if Field.FieldType <> 'Tx' then
        Continue;
      if (Field.Flags and TxProducerLayout) <> 0 then
        Report.Add(Format('%s: flags $%.8x, needs a producer /AP',
          [Field.Name, Field.Flags]))
      else if Field.MaxLength > 0 then
        Report.Add(Format('%s: /MaxLen %d, needs a producer /AP',
          [Field.Name, Field.MaxLength]))
      else if Field.Value = '' then
        Report.Add(Format('%s: blank, fill it or give it an /AP', [Field.Name]));
    end;
  finally
    Doc.Free;
  end;
end;

Скан читает флаги уровня поля — именно туда их кладут производители; рендерер также уважает /Ff на отдельном виджете, так что считайте список подспорьем для планирования, а диагностику рендеринга — последним словом. Проверка на пустоту важна, потому что текстовому полю без /V и без /AP нечего синтезировать: рендеринг останавливается с «A text-field widget without a value has no appearance to synthesize», а лекарство — значение или внешний вид прежде, чем файл дойдёт до рендерера

Что значит «Degenerate appearance geometry»?

Это значит, что у прямоугольника или бокса внешнего вида нет площади, и перевёрнутый /Rect — не причина. ISO 32000-1 §7.9.5 позволяет прямоугольнику называть любые два диагонально противоположных угла, и его примечание велит обрабатывающему софту быть готовым их нормализовать. Наивный рендерер, считающий ширину как x2 - x1, получает отрицательное число от [300 720 160 700] и либо отвергает аннотацию, либо зеркалит её внешний вид. HotPDF меняет координаты местами в порядок «левый нижний — правый верхний» до любой проверки размера, одинаково для синтезированных лиц и существующих потоков /AP, поэтому виджет 11 выше отрисовывается так, как будто его углы стоят канонически. Ошибка поднимается только для аннотации с потоком внешнего вида, когда после нормализации /Rect имеет нулевую ширину или высоту, либо /BBox потока схлопывается в линию или точку под своей /Matrix. Когда она появляется, выгрузите /Rect виджета вместе с /BBox и /Matrix потока: у одного из трёх нулевая протяжённость

Виджет без /AP и с /Rect нулевой площади идёт другим маршрутом: резервный синтез его пропускает и продолжает страницу. ISO 32000-2 §12.5.2 освобождает от требования внешнего вида только аннотации Popup, Projection и Link и прямоугольник, схлопнувшийся в единственную точку. Прямоугольник, представляющий собой просто линию, по PDF 2.0 всё ещё обязан иметь внешний вид; рендерер пропускает и его — просто потому, что красить нечего: нет площади

Когда тип поля найти не удаётся

«Widget field requires a name field type» значит, что рендерер поднялся от виджета настолько, насколько дотянулись ссылки /Parent, и не нашёл /FT. Рендерер стартует с массива /Annots страницы, поэтому восходящая ссылка /Parent — его единственная дорога к полю. Загрузка идёт в другую сторону, вниз от массива /Fields AcroForm через /Kids, оттого LoadFromFile может успешно завершиться на файле, чья страница позже откажется рендериться. Типичная причина — виджет, перечисленный в /Kids поля, но не указывающий назад. Таблица 188 ISO 32000-1 даёт здесь некоторое прикрытие: она требует /Parent на виджете, только когда виджет — один из нескольких детей, и говорит «absent otherwise», поэтому писатель, читающий её буквально, роняет ссылку для поля с одним отдельным виджетом. ISO 32000-2 смягчил ту же запись до «optional otherwise». Мы бы всё равно писали /Parent на каждом виджете, не слитом со своим полем. ISO 32000-2 это позволяет, и это единственный способ, которым ридер, ведомый страницей, — наш или чужой — может дотянуться до поля. Если производителя починить нельзя, слияние одинокого виджета в словарь его поля даёт тот же результат

Виджет, ссылающийся на родителя и находящий /DA где-то по пути вверх, отрисовывается правильно без всякого /AP в headless-рендерере компонента HotPDF для Delphi, что делает дерево полей, а не потоки внешнего вида, первым, что нужно выстроить правильно в Linux-конвейере форм