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

Слои PDF в Delphi: группы дополнительного содержимого (OCG)

Геодезист открывает план участка и хочет скрыть горизонтали рельефа, оставив включенными инженерные сети. Рецензент хочет видеть красные пометки на экране, но скрыть их при печати. Информационный лист продукта поставляется на трех языках в одном файле, и читатель выбирает нужный язык. Все это функции одного и того же механизма PDF, а панель для управления ими в Acrobat называется слоями (Layers). В основе этой панели лежит концепция дополнительного (необязательного) содержимого (optional content), которая позволяет одной странице нести несколько независимых визуальных слоев, включаемых и выключаемых программой просмотра

Дополнительное содержимое описывается в разделе §8.11 стандарта ISO 32000-1. Единицей видимости является группа дополнительного содержимого (Optional Content Group, OCG), представляющая собой словарь типа /OCG с именем группы. Помеченное содержимое на странице связывается с группой, а программа просмотра определяет, должна ли эта группа отображаться в данный момент. Смежная структура, словарь членства в дополнительном содержимом (OCMD), позволяет ставить видимость в зависимость от логической комбинации нескольких групп, однако на практике чаще всего используется одна именованная группа, представляющая один слой. Документ объединяет весь этот механизм через одну запись в каталоге, а именно /OCProperties, которая описана ниже

Что должен содержать каталог

Сама по себе группа OCG неактивна. Чтобы программа просмотра могла перечислить слой и запомнить его состояние, каталог документа должен содержать словарь /OCProperties, и раздел §8.11.4 точно определяет его структуру. В нем есть массив /OCGs, перечисляющий каждую группу в файле, и запись /D, содержащая конфигурацию по умолчанию. Конфигурация по умолчанию применяется при первом открытии файла. Она определяет, какие группы изначально включены, а какие выключены, какие записи заблокированы от изменений пользователем, а также задает порядок отображения и вложенность имен слоев в панели с помощью массива /Order

Структура групп необязательного содержимого PDF за PDF Library for Delphi: запись OCProperties каталога перечисляет каждый слой OCG и его конфигурацию по умолчанию со стартовыми состояниями, блокировками и порядком панели, а маркированное содержимое страницы привязывает рисунки к группам
Слои становятся адресуемыми только через каталог: OCProperties перечисляет каждый OCG, а конфигурация по умолчанию записывает начальные состояния, блокировки и порядок на панели

На практике создание слоя никогда не ограничивается локальными изменениями на странице. Группа должна быть отрисована на странице, а также зарегистрирована в структуре уровня каталога, которой раньше не существовало. PDF Library for Delphi выполняет обе задачи за вас. Первый вызов для создания группы добавляет запись /OCProperties в каталог и инициализирует конфигурацию по умолчанию, поэтому слой отрисовывается на странице и отображается в списке слоев без дополнительной ручной настройки

Почему режим соответствия стандартам может блокировать эту функцию

Перед выполнением кода управления слоями выбранный стандарт соответствия документа определяет, допустимо ли вообще использовать дополнительное содержимое. Спецификация PDF/A-1, представляющая собой архивный профиль в стандарте ISO 19005-1, полностью запрещает запись /OCProperties в разделе §6.1.13. Логика соответствует назначению формата. Архивный файл должен отображаться абсолютно одинаково у всех читателей в течение долгого времени, а содержимое, видимость которого пользователь может изменить, нарушает это правило. Поэтому профиль запрещает данную структуру во избежание двусмысленности. Стандарты PDF/A-2 и PDF/A-3, определенные в ISO 19005-2 и ISO 19005-3, придерживаются противоположной позиции в разделе §6.9 и разрешают дополнительное содержимое с соблюдением правил видимости по умолчанию

Это различие напрямую отражается в API. Когда документ находится в режиме PDF/A-1, функция NewOptionalContentGroup отказывается создавать группу и возвращает ноль, так как выполнение запроса привело бы к созданию файла, нарушающего заявленный стандарт соответствия. В режимах PDF/A-2 или PDF/A-3, а также в обычном свободном PDF тот же вызов завершается успешно и возвращает ненулевой идентификатор группы. Нулевой результат означает не общую ошибку, а указывает на то, что активный уровень соответствия стандартам не поддерживает данную функцию

var
  Pdf: TPDFlib;
  LayerID: Integer;
begin
  Pdf := TPDFlib.Create(nil);
  try
    Pdf.NewDocument;
    Pdf.SetPDFAMode(1);                       // PDF/A-1a: OCProperties forbidden

    LayerID := Pdf.NewOptionalContentGroup('Utilities');
    if LayerID = 0 then
      // отклонено в PDF/A-1; это не временная ошибка, режим запрещает слои
      ShowMessage('Optional content is not available in PDF/A-1 mode.');
  finally
    Pdf.Free;
  end;
end;

Два состояния для каждого слоя вместо одного

Слой не может быть просто видимым или невидимым. Конфигурация по умолчанию сохраняет его состояние на экране и отдельное состояние для печати, так как раздел §8.11.4 разделяет то, что программа просмотра показывает на экране, и то, что выводится при печати. Эти состояния независимы. Черновой водяной знак может отображаться на экране, но отсутствовать на бумаге, а слой с линиями реза может быть скрыт на экране, но отправлен на плоттер. Объединение этих состояний лишило бы нас гибкости управления, ради которой и создавалась эта функция

PDF Library for Delphi открывает доступ к этой паре через два метода установки значений. Метод SetOptionalContentGroupVisible принимает идентификатор группы и флаг (единица означает видимость, ноль указывает на скрытие) и управляет состоянием на экране по умолчанию. Метод SetOptionalContentGroupPrintable принимает идентификатор группы и флаг, определяющий вывод слоя при печати документа. Соответствующие методы получения значений, GetOptionalContentGroupVisible и GetOptionalContentGroupPrintable, возвращают единицу или ноль, позволяя считывать состояние экрана и печати независимо друг от друга

Матрица независимых состояний видимости на экране и при печати для слоёв PDF, созданных Delphi PDF Library: служебные слои видимы везде, разметка рецензента видна на экране, но никогда не печатается, а линия реза плоттера скрыта на экране, но печатается
Экранная видимость и печать задаются по группе, поэтому служебные слои могут появляться повсюду, а разметка рецензента остаётся заметкой только для экрана

Создание двух слоев на странице

Создание и заполнение слоя выполняются в строго определенном порядке. Вы отрисовываете содержимое слоя на текущей странице, а затем вызываете метод SetContentStreamOptional с идентификатором группы. Это действие оборачивает текущий поток содержимого страницы, привязывая все нарисованное к этой группе. Поскольку вызов фиксирует состояние потока на данный момент, правильный алгоритм состоит в отрисовке элементов одного слоя, их назначении и только затем переходе к следующему слою. Пример ниже размещает инженерные сети на первой странице, а пометки рецензента размещаются на второй странице, настраивает состояния видимости и печати для каждого слоя и сохраняет документ

var
  Pdf: TPDFlib;
  FontID, UtilLayer, RedlineLayer: Integer;
begin
  Pdf := TPDFlib.Create(nil);
  try
    Pdf.NewDocument;                          // unconstrained PDF: layers allowed
    Pdf.SetPageDimensions(595, 842);          // A4 in points
    FontID := Pdf.AddStandardFont(0);         // Helvetica
    Pdf.SelectFont(FontID);

    // Слой 1: служебные элементы, нарисованные и затем назначенные собственной группе
    Pdf.SetTextColor(0.10, 0.30, 0.65);
    Pdf.DrawText(72, 770, 'Utilities: water main, valve chamber');
    UtilLayer := Pdf.NewOptionalContentGroup('Utilities');
    Pdf.SetContentStreamOptional(UtilLayer);
    Pdf.SetOptionalContentGroupVisible(UtilLayer, 1);   // показан на экране
    Pdf.SetOptionalContentGroupPrintable(UtilLayer, 1); // и на бумаге

    // Слой 2: правка рецензента на новой странице
    Pdf.InsertPages(2, 1);                     // добавить одну страницу после страницы 1
    Pdf.SetTextColor(0.80, 0.10, 0.10);
    Pdf.DrawText(72, 770, 'REVIEW: revise valve spec before issue');
    RedlineLayer := Pdf.NewOptionalContentGroup('Reviewer markup');
    Pdf.SetContentStreamOptional(RedlineLayer);
    Pdf.SetOptionalContentGroupVisible(RedlineLayer, 1);    // видим при рецензировании
    Pdf.SetOptionalContentGroupPrintable(RedlineLayer, 0);  // never printed

    Pdf.SaveToFile('SitePlan_Layers.pdf');
  finally
    Pdf.Free;
  end;
end;

Слой с пометками рецензента заслуживает особого внимания. Он отображается на экране для рецензента, но его флаг печати равен нулю, поэтому при печати этого файла текст рецензии не выводится. Данная асимметрия как раз и объясняет необходимость разделения двух этих состояний

Чтение конфигурации

Чтение слоев представляет собой обратный процесс обхода той же структуры. После загрузки файла метод GetOptionalContentConfigCount сообщает количество словарей конфигурации в документе; первая конфигурация по умолчанию получает идентификатор 1. Внутри конфигурации функция GetOptionalContentConfigOrderCount возвращает количество записей в дереве порядка, индексация которых начинается с 1. Для каждой записи функция GetOptionalContentConfigOrderItemLabel возвращает отображаемый текст, а GetOptionalContentConfigOrderItemLevel возвращает глубину вложенности, что позволяет воссоздать структуру панели со вложенными слоями под заголовками

Каждая запись также имеет тип. Метод GetOptionalContentConfigOrderItemType позволяет отличить реальную группу дополнительного содержимого от обычной текстовой метки, служащей заголовком раздела дерева. Это разделение важно, так как запросы состояния группы имеют смысл только для реальных групп. Для записи группы метод GetOptionalContentConfigState сообщает, включает ли конфигурация ее изначально, отключает или оставляет без изменений, а GetOptionalContentConfigLocked указывает, запрещено ли пользователю переключать ее состояние. Приведенный ниже цикл выводит дерево порядка с указанием состояния и статуса блокировки каждой группы с отступами в зависимости от уровня вложенности

var
  Pdf: TPDFlib;
  Cfg, Count, I, ItemType, GroupID, Indent: Integer;
  Line: string;
begin
  Pdf := TPDFlib.Create(nil);
  try
    if Pdf.LoadFromFile('SitePlan_Layers.pdf', '') = 0 then Exit;
    if Pdf.GetOptionalContentConfigCount = 0 then Exit;

    Cfg := 1;                                  // конфигурация по умолчанию
    Count := Pdf.GetOptionalContentConfigOrderCount(Cfg);
    for I := 1 to Count do
    begin
      Indent := Pdf.GetOptionalContentConfigOrderItemLevel(Cfg, I);
      Line := StringOfChar(' ', Indent * 2)
              + Pdf.GetOptionalContentConfigOrderItemLabel(Cfg, I);

      ItemType := Pdf.GetOptionalContentConfigOrderItemType(Cfg, I);
      if ItemType = 1 then                     // 1 = группа дополнительного содержимого
      begin
        GroupID := Pdf.GetOptionalContentConfigOrderItemID(Cfg, I);
        case Pdf.GetOptionalContentConfigState(Cfg, GroupID) of
          1: Line := Line + '  [on]';
          2: Line := Line + '  [off]';
          3: Line := Line + '  [unchanged]';
        end;
        if Pdf.GetOptionalContentConfigLocked(Cfg, GroupID) = 1 then
          Line := Line + ' (locked)';
      end;
      // ItemType = 2 — заголовок текстовой метки; состояний отдельных групп нет

      Writeln(Line);
    end;
  finally
    Pdf.Free;
  end;
end;

Две детали обеспечивают корректность этого цикла. Индекс порядка начинается с единицы (от 1 до общего количества), что соответствует внутреннему представлению дерева в библиотеке. Кроме того, вызовы для конкретных групп выполняются только тогда, когда тип элемента является группой, поскольку текстовая метка представляет собой просто заголовок с именем и уровнем, но не имеет состояний включения, выключения или блокировки. Без этой проверки вы запросите у текстовой метки состояние, которого у нее нет

Цикл обратного чтения конфигураций необязательного содержимого в PDF Library for Delphi: перечислить конфигурации, обойти записи порядка с единицы, затем ветвиться по ItemType, чтобы состояние on, off или locked запрашивалось только у настоящих групп, а текстовые метки пропускались
Обратное чтение слоёв обходит записи порядка с единичной базой и запрашивает состояние только у настоящих групп, ведь у заголовка-метки нет ни состояния on, ни off, ни locked

Сфера применения

Слои служат механизмом представления, поэтому движок должен учитывать их при любом способе рендеринга страницы. Вопросы визуализации подробно описаны в нашем руководстве по многодвижковому рендерингу в Delphi. Слои также связаны со структурой документа, поскольку имя слоя представляет собой видимый пользователю текст, а структурированное дерево слоев упрощает навигацию, что перекликается с материалом в нашей статье о тегированных PDF и структуре доступности. Обе эти темы тесно связаны с описанным здесь API дополнительного содержимого, которое поставляется в составе библиотеки Delphi PDF Library вместе с инструментами для работы со страницами, текстом, шрифтами и стандартами соответствия