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

Блоки-шаблоны PDF в Delphi: заливка текстом, картинкой, PDF

PDFlibPas (losLab PDF Library) заполняет PDF-шаблоны из блоков силами Delphi: блок — это именованный прямоугольник на странице-шаблоне с типом text, image или PDF, а FillTextBlock, FillImageBlock и FillPDFBlock рисуют в него переменные данные. Блоки находятся как аннотации страницы PDFlibBlock:Text, PDFlibBlock:Image и PDFlibBlock:PDF либо в словаре page-piece под /PieceInfo

Сценарий использования — скучный и массовый. Дизайнер один раз свёрстывает сертификат, билет на мероприятие или письмо о задолженности, а программа штампует имя, номер места, картинку подписи или заранее отрендеренную страницу штрихкода в фиксированные слоты для десяти тысяч записей. Поля форм умеют часть этого, но поле AcroForm — интерактивный объект со своим appearance stream и своими правилами, и вы всё равно обычно его сплющиваете. Блок проще: прямоугольник плюс горстка стилевых подсказок, использованный один раз и выброшенный

Что такое PDF-блок в PDFlibPas на самом деле?

Блок PDFlibPas — это аннотация страницы, у которой /Subtype равен PDFlibBlock:Text, PDFlibBlock:Image или PDFlibBlock:PDF, /Rect — область-заполнитель, а строка /Contents хранит name;key=value;key=value. Первый сегмент — имя блока, всё после него — список свойств. TPDFlib.AddBlock пишет аннотацию с /F 4 (Print) и без appearance stream, так что незаполненный блок не занимает ни одного пикселя ни на экране, ни на бумаге

Анатомия блочного шаблона PDFlibPas для переменных данных в Delphi: аннотация страницы с /Subtype PDFlibBlock:Text, Image или PDF, /Rect-заполнитель, строка /Contents с именем и свойствами key=value, /F 4 Print и отсутствие appearance stream, а словарь page-piece /PieceInfo /PDFlib /Blocks как второй источник
Блоки перечисляются с единицы постранично: сначала аннотации, затем page-piece блоки; элементы массива получают имена Block1 и Block2, ключи /Custom разворачиваются в свойства, а всё, что не Image, PDF или Page, трактуется как текст

У чтения есть второй источник. После перечисления блочных аннотаций PDFlibPas откатывается к пути словаря страницы /PieceInfo << /PDFlib << /Blocks ... >> >> и его варианту /PieceInfo << /PDFlib << /Private << /Blocks ... >> >> >>. Словари page-piece — стандартный PDF, определённый в ISO 32000-1 §14.5 как место, где приложение хранит приватные данные, прикреплённые к странице, и именно там обычно живут определения блоков из внешних инструментов разметки шаблонов. Контейнер /Blocks может быть словарём с ключами-именами блоков или простым массивом; для массивной формы PDFlibPas называет элементы Block1, Block2 и так далее, если только блок не несёт собственного /Name

Внутри page-piece блока значение /Subtype (или /Type) Image отображается на image-блок, PDF или Page — на PDF-блок, а всё остальное трактуется как текст. Каждый прочий ключ, кроме /Type, /Subtype, /Name, /Rect и /Custom, становится свойством, и ключи вложенного словаря /Custom тоже разворачиваются внутрь, так что запись /Custom << /department (Finance) >> читается как GetBlockProperty(Page, Index, 'department'). Индексы блоков отсчитываются с единицы постранично: сначала блочные аннотации, после них page-piece блоки

Разметка блочного шаблона через AddBlock

TPDFlib.AddBlock(BlockType, BlockName, Left, Top, Width, Height, Properties) добавляет блок на выбранную страницу и возвращает 1 при успехе. Он возвращает 0 для пустого имени, нулевой или отрицательной ширины либо высоты, а также BlockType, отличного от 'text', 'image' или 'pdf' (без учёта регистра). Properties дописывается к имени через точку с запятой, так что синтаксис тот же key=value;key=value, что и в сохранённой строке

var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;
  try
    // статичная графика: рамка, логотип, неизменный текст ...
    Lib.DrawText(72, 200, 'This certifies that');

    Lib.AddBlock('text', 'recipient', 72, 230, 450, 44,
      'fontname=Times-Bold;fontsize=30;fillcolor={cmyk 0 0.55 1 0.1};alignment=center');
    Lib.AddBlock('text', 'course', 72, 290, 450, 24,
      'fontname=Helvetica;fontsize=14;alignment=center;fitmethod=word');
    Lib.AddBlock('image', 'signature', 360, 600, 160, 60, '');
    Lib.AddBlock('pdf', 'seal', 72, 580, 100, 100, '');

    if Lib.GetBlockCount(1) <> 4 then
      raise Exception.Create('Template blocks missing');
    Lib.SaveToFile('certificate-template.pdf');
  finally
    Lib.Free;
  end;
end;

Парсер свойств нарочно нетребователен. Ключи сопоставляются без учёта регистра, значения обрезаются по краям, но в остальном возвращаются как есть (значение, записанное как {top left}, вернётся со своими скобками), а отсутствующий ключ возвращает пустую строку. Единственный жёсткий предел — разделитель: значение не может содержать точку с запятой, потому что точка с запятой заканчивает сегмент

Как FillTextBlock применяет шрифт, цвет и выравнивание?

TPDFlib.FillTextBlock(Page, BlockIndex, Text, Options) читает свойства блока, применяет их к состоянию рисования, вгоняет текст в прямоугольник блока через FitTextBox, а затем возвращает шрифт, размер текста, цвет текста и выравнивание вызывающего в блоке finally. Это восстановление важно в цикле слияния: красный заголовочный блок на 30 pt не должен утекать своим стилем в следующий ваш DrawText на той же странице

  • fontname (или font): сперва повторно использует шрифт, уже загруженный в документ под этим именем, затем отображает имена Standard 14 вроде Helvetica-Bold, Times-Roman или Courier, и напоследок пробует AddTrueTypeFont с этим именем
  • fontsize (или size): размер текста, переданный фиттеру, который ужмёт его, если текст не влезает
  • fillcolor (или textcolor, color): #RRGGBB, {rgb r g b}, {cmyk c m y k}, {gray g}, голые списки из 3 или 4 чисел либо имена black, white, red, green, blue и gray; компоненты RGB выше 1 читаются как 0–255
  • alignment, align и position читаются вместе: left, right, center (или centre) и justify задают горизонтальное выравнивание, top и bottom — вертикальное размещение, а по умолчанию блок центрирован по вертикали
  • fitmethod: word или nofitsplit не даёт длинным словам ломаться, scale или expand позволяет размеру вырасти и заполнить бокс

CMYK-путь — причина, по которой типографии за этим следят. {cmyk 0 0.55 1 0.1} вызывает SetTextColorCMYK, так что переменный текст выпускается в DeviceCMYK и попадает в фирменный цвет на печати без RGB-круговорота. FillTextBlock возвращает 1, когда текст нарисован; он возвращает 0 для пустого Text, отсутствующего блока или блока не текстового типа, и во всех этих случаях блок остаётся на месте

Порядок обработки FillTextBlock в PDFlibPas для Delphi: свойства блока fontname, fontsize, fillcolor и alignment применяются к состоянию рисования, значение CMYK идёт через SetTextColorCMYK, текст вгоняется через FitTextBox, а шрифт, размер, цвет и выравнивание вызывающего возвращаются в блоке finally
Стилевые свойства блока применяются однократно и откатываются, так что красный заголовок 30 pt не утекает в следующий DrawText; вызов возвращает 1, когда текст нарисован, и 0 с блоком на месте при пустом тексте или блоке не текстового типа

Заполнение image- и PDF-блоков и биты Options

FillImageBlock(Page, BlockIndex, ImageID, Options) рисует ранее добавленное изображение, а FillPDFBlock(Page, BlockIndex, SourceDocumentID, SourcePage, Options) импортирует страницу другого загруженного документа как Form XObject и рисует её, сохраняя векторное содержимое вроде печати, страницы с QR-кодом или бланка письма полностью векторным. Оба сохраняют пропорции источника и центрируют результат внутри прямоугольника блока. Все три функции заливки делят одни и те же биты Options: бит 1 (значение 1) удаляет блок после успешной заливки — стирает аннотацию или убирает запись из контейнера /Blocks, а бит 2 (значение 2) растягивает image- и PDF-содержимое точно на прямоугольник вместо вгонки

function FillCertificate(const TemplateFile, SealFile, SignatureFile,
  OutFile: WideString; Values: TStrings): Boolean;
var
  Lib: TPDFlib;
  SealID, SignatureID, I: Integer;
  BlockName, BlockType: WideString;
begin
  Result := False;
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile(SealFile, '') <> 1 then Exit;
    SealID := Lib.SelectedDocument;          // настоящий ID документа
    if Lib.LoadFromFile(TemplateFile, '') <> 1 then Exit;
    // теперь шаблон — выбранный документ
    SignatureID := Lib.AddImageFromFile(SignatureFile, 0);

    // идём назад: бит 1 удаляет каждый блок и перенумеровывает последующие
    for I := Lib.GetBlockCount(1) downto 1 do
    begin
      BlockName := Lib.GetBlockName(1, I);
      BlockType := Lib.GetBlockType(1, I);
      if BlockType = 'text' then
        Lib.FillTextBlock(1, I, Values.Values[BlockName], 1)
      else if (BlockType = 'image') and (SignatureID <> 0) then
        Lib.FillImageBlock(1, I, SignatureID, 1)
      else if BlockType = 'pdf' then
        Lib.FillPDFBlock(1, I, SealID, 1, 1);
    end;
    Result := Lib.SaveToFile(OutFile) = 1;
  finally
    Lib.Free;
  end;
end;

Две детали в этом цикле легко испортить. Цикл идёт от старшего индекса вниз, потому что удаление блока 2 превращает старый блок 3 в блок 2; счёт вверх при включённом бите 1 пропускает блоки через один. А исходный документ для FillPDFBlock загружается первым, и его ID захватывается до загрузки шаблона, потому что ID изображений принадлежат выбранному документу, так что AddImageFromFile обязан выполниться, пока выбран шаблон. Для пакетных прогонов, где печать или бланк меняются от записи к записи, сшивка страниц Form XObject и компонуемые шаблоны водяных знаков в PDFlibPas используют тот же механизм захвата и отрисовки на целых страницах

Почему координаты блока выходят перевёрнутыми?

Прямоугольники блоков выходят перевёрнутыми, когда код смешивает две системы координат, с которыми имеет дело PDFlibPas. Аннотационный /Rect хранится в пользовательском пространстве PDF и меряется от низа страницы (ISO 32000-1 §12.5.2), а рисующий API TPDFlib меряет от верха. AddBlock принимает ввод от верха и сохраняет нижнюю грань как PageHeight - Top - Height; GetBlockRect и три функции заливки читают прямоугольник обратно и преобразуют его как PageHeight - URy

На практике это значит: когда вам нужна геометрия слота, скажем чтобы нарисовать отладочную рамку или поставить подпись рядом с блоком, берите GetBlockRect(Page, BlockIndex, Left, Top, Width, Height) и не читайте /Rect через универсальный вызов аннотаций, чтобы скормить его напрямую DrawText. Page-piece блок с /Rect [40 700 240 730] на буквенной странице 792 pt сообщает Top = 62, а не 700. Баг, который это предотвращает, — классический: все заполненные значения ложатся зеркально у нижнего поля, и ни одной ошибки

Переворот координат прямоугольника блока в PDFlibPas для Delphi: значения /Rect живут в отсчитываемом от низа пользовательском пространстве PDF, тогда как AddBlock принимает ввод от верха и сохраняет PageHeight минус Top минус Height, поэтому /Rect 40 700 240 730 на буквенной странице 792 pt читается через GetBlockRect как Top 62 с преобразованием PageHeight минус URy
Смешение двух систем координат зеркалит все заполненные значения к нижнему полю без единой ошибки; читайте геометрию слота через GetBlockRect, а не скармливайте /Rect напрямую в DrawText

Грабли, на которых стоит учиться

Самый поучительный баг в блочном коде был ошибкой в один символ. Блочные аннотации распознаются проверкой префикса сабтайпа, а 'PDFlibBlock:' имеет длину 12 символов. Ранняя версия сравнивала Copy(Subtype, 1, 13) с тем 12-символьным литералом, что не может быть равно никогда, поэтому GetBlockCount возвращал 0 для любого шаблона, а каждая заливка сообщала "no such block". Ничего не падало и ничего не писалось в лог. Урок обобщается на любой шаблонный движок: утверждайте количество блоков известного шаблона в тесте, потому что молчаливое перечисление с нулём совпадений выглядит в точности как пустая страница

Вторая ловушка — ID документов. TPDFlib.LoadFromFile возвращает код статуса, 1 при успехе и 0 при неудаче, а не ID только что загруженного документа. Передача этой единицы как SourceDocumentID в FillPDFBlock проваливается, потому что 1 — не валидный ID документа. Вызывайте SelectedDocument сразу после успешной загрузки, чтобы получить настоящий ID, и SelectDocument, чтобы вернуться к шаблону, если после загрузили источник

Ещё одна поменьше: FillTextBlock с пустой строкой не делает ничего и сохраняет блок. Для mail merge с необязательными полями, вроде отсутствующей второй строки адреса, это обычно то, что нужно, но незаполненная аннотация остаётся в выходном файле. Удаляйте её явно через DeleteAnnotation или залейте одиночный пробел с битом 1, если вывод обязан быть чистым. Все блочные вызовы сохраняют и восстанавливают выбранную страницу, так что заливка на странице 3 не меняет, куда пойдёт ваш следующий рисующий вызов

Каковы пределы совместимости блочных аннотаций?

Блочные аннотации — частная конвенция, а не стандартная возможность PDF. PDFlibBlock:Text и его собратья не входят в типы аннотаций, перечисленные в ISO 32000-1 §12.5.6.1, поэтому вьювер считает их неизвестными аннотациями. Поскольку флаг Invisible сброшен и appearance stream нет, конформные вьюверы не рисуют для них ничего, но некоторые вьюверы всё же могут показать их в панели комментариев, а другие PDF-инструменты не поймут и не сохранят их семантику

  • Заливайте с битом 1 для всего, что покидает вашу систему, чтобы итоговый файл содержал только обычное содержимое страницы
  • PDF/A не допускает типы аннотаций вне спецификации PDF (ISO 19005-1 §6.5.2), поэтому архивный вывод не должен нести незаполненных блочных аннотаций
  • Page-piece блоки безопаснее хранить в шаблоне, поскольку /PieceInfo стандартен, а потребители игнорируют данные, которыми не владеют
  • Блоки — не поля форм: никакой интерактивности, никакого значения поля в файле и никакого экспорта в FDF или XFDF; для данных, которые обязаны оставаться редактируемыми, см. обмен данными форм FDF, XFDF и XFA в PDFlibPas

Если шаблон обязан объявлять переменные поля стандартизованным, проверяемым валидатором способом для печатного конвейера, блоки — не тот инструмент. Для этого существует PDF/VCR-1, и манифест плейсхолдеров шаблона PDF/VCR-1 в PDFlibPas рассказывает, как такие плейсхолдеры живут в дереве структуры, а не в аннотациях

FAQ

Может ли PDFlibPas заполнить блочные шаблоны, созданные другими инструментами?

Да, если инструмент хранит блоки под /PieceInfo /PDFlib /Blocks или /PieceInfo /PDFlib /Private /Blocks, в форме словаря или массива, с /Rect из четырёх чисел. GetBlockCount считает их после любых аннотаций PDFlibBlock:*, а пользовательские ключи доступны через GetBlockProperty

Меняет ли FillTextBlock шрифт, который я выбрал до вызова?

Нет. FillTextBlock сохраняет выбранный шрифт, размер текста, цвет текста и выравнивание, применяет свойства блока fontname, fontsize, fillcolor и alignment, а затем восстанавливает исходное состояние — даже если вгонка провалилась

Как положить в блок векторный логотип или страницу со штрихкодом?

Загрузите исходный PDF, сохраните SelectedDocument как его ID, при необходимости снова выберите документ-шаблон и вызовите FillPDFBlock(Page, BlockIndex, SourceID, SourcePage, 1). Страница импортируется как Form XObject, поэтому остаётся векторной и вгоняется в блок с сохранением пропорций, если вы не добавили бит 2

Блочные шаблоны, импорт страниц Form XObject и остальной инструментарий переменных данных входят в PDFlibPas, losLab PDF Library for Delphi — библиотеку создания, редактирования и печатного производства PDF для проектов на Delphi и C++Builder