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

Переключение слоёв Optional Content PDF в Delphi (PDFium)

Комплект чертежей приходит с размерами на собственном слое, и очевидный способ их спрятать — перенести ссылку на эту группу из массива /ON конфигурации по умолчанию в /OFF. Файл сохраняется и открывается заново, а размерные линии всё ещё напечатаны поперёк основной надписи. Причина сидит на один объект дальше, внутри словаря группы: /Usage << /View << /ViewState /ON >> >> — явное состояние, которое рендерер читает раньше конфигурационных массивов

Состояние слоя может жить в PDF в нескольких местах, и правка /OCProperties трогает лишь одно из них. Движок PDFium от Google рисует optional content корректно, но его публичный C API не имеет функции перечислить группы optional content (OCG), выбрать конфигурацию или переключить группу. Delphi-коду, управляющему слоями, приходится переписывать объекты самому и в точности переигрывать порядок решений рендерера — иначе его список слоёв показывает одно состояние, а страница рисует другое

Почему правка /ON и /OFF не меняет то, что отрисовывает PDFium?

Массивы /ON и /OFF — лишь второй из пяти шагов, и два последующих могут их перебить. ISO 32000-1 §8.11.4.3 определяет словарь конфигурации optional content с /BaseState, /ON, /OFF, /Locked, /RBGroups и /AS, а §8.11.4.4 — словарь Usage, который может нести OCG. В спецификации Usage описывает, зачем группа существует, а записи авто-состояния в /AS превращают это описание в ON или OFF. PDFium идёт короткой дорогой: когда он рисует с целью, собственное явное состояние группы для этой цели побеждает. Сложенные вместе, шаги выглядят так:

  • Стартуем от /BaseState конфигурации, где /ON и /Unchanged оба считаются видимыми и только /OFF прячет
  • Применяем массив /ON, затем массив /OFF, так что группа, попавшая в оба, оказывается скрытой
  • Применяем явное состояние Usage группы для запрошенной цели, например /Usage << /View << /ViewState /OFF >> >>, — оно переигрывает всё вышеперечисленное
  • Группу, чей /Intent не содержит ни /View, ни /All, считаем видимой: она не участвует в видимости с view-намерением
  • Массив /AS выбранной конфигурации бежит последним, и записи под совпавшее событие задают состояние перечисленных групп
Пятишаговое решение о видимости, которое PDFium Component переигрывает для каждой группы optional content PDF в Delphi: BaseState задаёт старт, массивы ON и OFF конфигурации применяются по порядку, явная запись Usage ViewState или PrintState переигрывает обе, неучастие Intent считается видимым, а массив AS бежит последним
Правка массивов ON и OFF недостаточна, потому что PDFium сворачивает BaseState, оба массива, Usage-состояние группы и, наконец, правила авто-состояния AS в один вердикт, который InspectOptionalContent воспроизводит шаг за шагом

Шаг три объясняет файл из начала статьи: некоторые экспортёры пишут /ViewState /ON в каждую созданную группу, и с тех пор никакая правка массивов эти слои не спрячет. Печать и экспорт следуют тому же правилу с одним поворотом: рендерер сначала читает /PrintState или /ExportState и откатывается к /ViewState, когда конкретной записи нет. Одиночное /ViewState /ON прикалывает слой и на бумаге. Шаг четыре тише. Группа с тегом /Intent /Design вообще вне видимости view-намерения и рисуется независимо от массивов, тогда как группа без ключа /Intent трактуется как /View — значение по умолчанию, которое даёт спецификация

Список слоёв и их отрисованного состояния через InspectOptionalContent

PDFium Component переигрывает этот порядок в TPdf.InspectOptionalContent. Метод наполняет TPdfOptionalContentInventory и возвращает True при успехе; параметр Usage по умолчанию ocuView, параметр ConfigurationIndex — -1, выбирающий индекс 0. Индекс конфигурации 0 — это словарь /D по умолчанию, а записи /Configs идут с индекса 1 в порядке файла. Перед сканированием метод заставляет PDFium сохранить открытый документ в память и развернуть object-потоки, так что правки, сделанные ранее в сессии, видны в результате. Файл без /OCProperties или индекс конфигурации вне доступного диапазона заставляют вызов вернуть False с причиной в ErrorMessage; исключение в обоих случаях не возбуждается

procedure TFormMain.ListLayers;
var
  Inv: TPdfOptionalContentInventory;
  Cfg: TPdfOptionalContentConfiguration;
  G: TPdfOptionalContentGroup;
begin
  // Usage по умолчанию ocuView; -1 выбирает конфигурацию 0, словарь /D
  if not Pdf.InspectOptionalContent(Inv) then
  begin
    Memo1.Lines.Add('No usable layers: ' + Inv.ErrorMessage);
    Exit;
  end;
  Cfg := Inv.Configurations[Inv.SelectedConfigurationIndex];
  Memo1.Lines.Add(Format('Configuration %d "%s", %d groups, /AS present: %s',
    [Inv.SelectedConfigurationIndex, string(Cfg.Name), Length(Inv.Groups),
     BoolToStr(Cfg.HasAutomaticState, True)]));
  for G in Inv.Groups do
    Memo1.Lines.Add(Format('obj %d  %-24s visible=%s  locked=%s  radio=%d',
      [G.ObjectNumber, string(G.Name),
       BoolToStr(G.EffectiveVisible, True),
       BoolToStr(G.Locked, True), G.RadioGroupIndex]));
end;

Каждый TPdfOptionalContentGroup несёт номер объекта, имя, Intents и три явных состояния ViewState, PrintState и ExportState, каждое из ocsUnspecified, ocsOn или ocsOff. Когда состояние читается ocsOn, а группа сидит в массиве /OFF, вы нашли ловушку из первого абзаца. Locked и RadioGroupIndex приходят из выбранной конфигурации, и RadioGroupIndex равен -1 для группы вне всякого набора /RBGroups

Остаток словаря Usage сообщается так, как его заявил файл: Language и LanguagePreferred, UserType и UserNames, PageElementSubtype, а также MinimumZoom и MaximumZoom за флагом HasZoomRange. EffectiveVisible не зависит ни от чего из этого. Показ, зависящий от зума, — забота вашего просмотрщика. Каждый TPdfOptionalContentConfiguration выставляет имя, BaseState, содержимое массивов и RadioGroups, плюс OrderGroupObjectNumbers, разворачивающее /Order в ссылки на группы и пропускающее текстовые метки вложенных массивов-меток

Печать и экспорт могут расходиться с экраном

Аргумент Usage меняет ответ — ради этого он и существует. При ocuPrint инвентаризация читает /PrintState с откатом к /ViewState, и участвуют только записи /AS с /Event равным /Print. Слой-водяной знак, построенный появляться только на бумаге, сообщит «скрыт» при ocuView и «виден» при ocuPrint. Сравнение двух проходов находит такие слои раньше, чем распечатка удивит кого-то

procedure TFormMain.ReportPrintOnlyLayers;
var
  OnScreen, OnPaper: TPdfOptionalContentInventory;
  I: Integer;
begin
  if not Pdf.InspectOptionalContent(OnScreen, ocuView) or
     not Pdf.InspectOptionalContent(OnPaper, ocuPrint) then
    Exit;
  // Оба прохода читают один массив /OCGs, поэтому порядок групп совпадает
  for I := 0 to High(OnScreen.Groups) do
    if OnScreen.Groups[I].EffectiveVisible <> OnPaper.Groups[I].EffectiveVisible then
      Memo1.Lines.Add(Format('%s: screen=%s, print=%s',
        [string(OnScreen.Groups[I].Name),
         BoolToStr(OnScreen.Groups[I].EffectiveVisible, True),
         BoolToStr(OnPaper.Groups[I].EffectiveVisible, True)]));
end;

Как вычисляются выражения видимости OCMD?

Словарь членства optional content (OCMD, ISO 32000-1 §8.11.2.2) привязывает кусок содержимого к нескольким группам через политику /P или через выражение видимости /VE, комбинирующее группы с /And, /Or и /Not. Спецификация даёт /P значение по умолчанию /AnyOn, позволяет /VE иметь приоритет над /OCGs и /P, когда оно присутствует, и говорит, что OCMD без групп не влияет на видимость

Массив Memberships инвентаризации следует этим правилам против погрупповых результатов того же прохода, так что запрошенное использование протаскивается насквозь. Каждое членство сообщает номера объектов групп, Policy (ocmpAnyOn, ocmpAllOn, ocmpAnyOff, ocmpAllOff или ocmpUnknown для имени вне этих четырёх), сырой текст VisibilityExpression и собственный EffectiveVisible. OCMD без групп считается видимым. Ссылка на номер объекта, не являющийся известным OCG, тоже считается видимой, так что одна висящая ссылка не прячет всё вокруг. Вычисление /VE останавливается на глубине вложенности 32 и считает всё глубже скрытым, так что самоссылающееся выражение не переполнит стек. Сканирование перечисляет каждый OCMD, хранящийся как косвенный объект с /Type /OCMD, включая object-потоки

Пишем новое состояние слоя методом SaveAsOptionalContentConfigured

TPdf.SaveAsOptionalContentConfigured принимает массив записей TPdfOptionalContentStateChange, каждая с GroupObjectNumber и флагом Visible. Он пишет копию, в которой выбранная конфигурация даёт ровно это состояние в пятишаговом порядке. Группы, которых вы не упомянули, сохраняют видимость, вычисленную для них инвентаризацией

procedure TFormMain.SaveWithoutDimensions(DimensionsObj, NotesObj: Integer;
  ForPrint: Boolean);
var
  Changes: TPdfOptionalContentStateChanges;
  Options: TPdfOptionalContentConfigureOptions;
  Report: TPdfOptionalContentConfigureReport;
begin
  SetLength(Changes, 2);
  Changes[0].GroupObjectNumber := DimensionsObj;
  Changes[0].Visible := False;
  Changes[1].GroupObjectNumber := NotesObj;
  Changes[1].Visible := True;

  Options := TPdfOptionalContentConfigureOptions.Default;
  if ForPrint then
  begin
    Options.Usage := ocuPrint;         // пишет /Print << /PrintState ... >>
    Options.ConfigurationIndex := 1;   // первая запись /Configs, /D остаётся как было
  end;

  if not Pdf.SaveAsOptionalContentConfigured('C:\Out\Drawing-NoDims.pdf',
    Changes, Options, Report) then
    raise Exception.Create('Layer update rejected: ' + Report.ErrorMessage);

  Log(Format('%d of %d groups changed, %d Usage states rewritten, /AS removed: %s',
    [Report.ChangedGroupCount, Report.GroupCount,
     Report.UpdatedUsageStateCount,
     BoolToStr(Report.RemovedAutomaticState, True)]));
end;

Вывод держит собственный сохранённый PDFium документ байта-в-байт префиксом и дописывает только переписанного владельца конфигурации и объекты OCG, несущие словари Usage, за ними новую секцию xref и трейлер. Прежде чем хоть что-то попадёт в приёмник, результат заново открывается в отдельном TPdf под строгой политикой загрузки, и метод проваливается, если та загрузка не удалась или таблица кросс-ссылок не валидна. Файловая перегрузка пишет во временный файл рядом с целью и заменяет цель только после проверки, так что отвергнутое обновление оставляет предыдущий чертёж нетронутым. Это тот же подход верифицированных инкрементальных ревизий, что использует редактор name tree и number tree в PDFium Component. Чтобы подтвердить печатный пресет, перезагрузите вывод и вызовите InspectOptionalContent(Inv, ocuPrint, 1); группы должны сообщить переданные вами состояния

Как SaveAsOptionalContentConfigured в PDFium Component пишет PDF с переключёнными слоями в Delphi: на вход идут изменения состояний и опции, выбранная конфигурация переписывается с полными массивами ON и OFF и Usage-состояниями, дописывается верифицированная инкрементальная ревизия, и строгое переоткрытие обязано провалидироваться, прежде чем хоть что-то будет записано
Сконфигурированное сохранение держит собственный re-save PDFium байтовым префиксом, дописывает переписанного владельца конфигурации плюс новую секцию xref и переоткрывает результат в отдельном TPdf, прежде чем тронуть приёмник

От чего отказывается сконфигурированное сохранение?

Сконфигурированное сохранение отказывает любому изменению, которое документ запрещает или не может безопасно нести, и каждый отказ случается до записи приёмника. Проверки идут в фиксированном порядке. Подписанный документ возбуждает EPdfError прежде всего остального, если вы не передали AllowSignedDocument = True. Зашифрованный документ отвергается следующим: plaintext-инкрементальные объекты не могут нести активный security handler. Затем проверяется каждое запрошенное изменение: номер объекта вне /OCGs проваливается сразу, изменение группы из массива /Locked конфигурации проваливается, хотя повторение её текущего значения проходит. Последним, при включённом EnforceRadioGroups, отвергается набор /RBGroups, который оказался бы больше чем с одним видимым членом. Каждый отказ, кроме подписи, возвращает False с причиной в Report.ErrorMessage

Ворота отказов, которые SaveAsOptionalContentConfigured в PDFium Component ставит перед записью сконфигурированного PDF в Delphi: номер объекта вне OCGs проваливается, заблокированные группы проваливаются, наборы RBGroups больше чем с одним видимым членом отвергаются, зашифрованные документы не могут нести plaintext-инкрементальные объекты, а подписанные требуют AllowSignedDocument
Каждый отказ случается прежде, чем тронут приёмник; причина попадает в Report.ErrorMessage, а для подписанного файла — в сообщение EPdfError

Двое из этих ворот идут дальше, чем требует спецификация в строгом смысле. §8.11.4.3 описывает /Locked как защиту от изменений через пользовательский интерфейс просмотрщика и описывает группы-радиокнопки как выключение прочих членов при включении одного. PDFium Component применяет оба правила и к программным изменениям, а для радиогрупп отказывается вместо того, чтобы выбирать, кого выключить. Спрошенный за двух членов взаимоисключающего набора, библиотека не может знать, которого вы имели в виду, и догадка противоречила бы вашему запросу. Выключите остальных в том же массиве Changes — и сохранение пройдёт

Ворота подписанного документа существуют потому, что дописанная ревизия ложится поверх полного re-save PDFium, а это новая байтовая последовательность; нельзя считать, что диапазон байтов, который покрывает существующая подпись, переживёт её. Передавайте AllowSignedDocument = True только когда потеря этой подписи приемлема

Три побочных эффекта важны в пакетной задаче. Перезапись нормализует выбранную конфигурацию к /BaseState /ON, так что базовая линия автора /Unchanged или /OFF становится явными массивами с той же итоговой видимостью. Выброс /AS заодно убирает печатные трюки вроде слоя-водяного знака, появляющегося только на бумаге; выставьте DisableAutomaticState в False, чтобы сохранить эти правила, приняв, что они могут перебить ваше запрошенное состояние для своего события. И массивы /ON и /OFF пишутся из состояний, вычисленных для Options.Usage, так что печатный пресет, сохранённый в конфигурацию 0, меняет и то, что показывает экран, для каждой группы без словаря Usage. Держите печатные пресеты в записи /Configs, а /D оставьте экрану. На стороне соответствия PDF/A-2 (ISO 19005-2, пункт 6.9) и PDF/UA (ISO 14289-1, пункт 7.10) оба запрещают /AS в словарях конфигураций, так что вывод по умолчанию снимает одну находку, которую иначе сообщила бы PDF/A preflight-валидация с PDFium Component

Применение изменений слоёв внутри просмотрщика Delphi

В просмотрщике управление слоями — это чек-лист, построенный из инвентаризации, за которым сохранение в память и перезагрузка. Наполняйте чек-лист из Groups, блокируйте записи с выставленным флагом Locked, считайте члены с общим RadioGroupIndex взаимоисключающими, а при применении пишите в TMemoryStream через потоковую перегрузку. TPdf.LoadDocument ожидает неактивный компонент, так что выключите его сначала; буферизованная загрузка копирует байты, что позволяет сразу освободить поток

procedure TFormMain.ApplyLayerChecklist(
  const Changes: TPdfOptionalContentStateChanges);
var
  Report: TPdfOptionalContentConfigureReport;
  Output: TMemoryStream;
  KeepPage: Integer;
begin
  Output := TMemoryStream.Create;
  try
    try
      if not Pdf.SaveAsOptionalContentConfigured(Output, Changes,
        TPdfOptionalContentConfigureOptions.Default, Report) then
      begin
        ShowMessage('Layer update rejected: ' + Report.ErrorMessage);
        Exit;
      end;
    except
      on E: EPdfError do
      begin
        ShowMessage(E.Message);       // подписанный файл: Output всё ещё пуст
        Exit;
      end;
    end;
    KeepPage := Pdf.PageNumber;
    Pdf.Active := False;
    Pdf.LoadDocument(Output);         // Buffered по умолчанию True
    Pdf.PageNumber := KeepPage;
  finally
    Output.Free;
  end;
end;

После перезагрузки PDFium рисует из новой конфигурации, так что экран и следующий вызов InspectOptionalContent согласованы. Проводка между TPdf и TPdfView разобрана в статье о построении многофункционального просмотрщика PDF с PDFium VCL в Delphi. Когда у документа ещё нет слоёв и их нужно добавить, создание PDF-слоёв с OCG и OCMD в Delphi проходит по шагам через SaveAsOptionalContentCreated

Цены, пробная сборка и прочие документные возможности компонента перечислены на странице PDFium Component for Delphi product page