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

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

PDFium Component управляет слоями optional content PDF (OCG) в Delphi через два метода TPdf: InspectOptionalContent перечисляет каждый слой вместе с видимостью, которую PDFium реально отрисует, а SaveAsOptionalContentConfigured пишет проверенную копию, в которой выбранные вами слои включены или выключены. Второй метод заодно нейтрализует правила Usage и /AS, которые иначе тихо откатили бы вашу правку. Оба работают с документом, уже открытым в TPdf, так что второго парсера, синхронизируемого с тем, что показывает вьюер, не появляется

Запрос обычно прилетает из CAD- или GIS-конторы: комплект чертежей уходит с размерами, аннотациями и основной надписью на отдельных слоях, а заказчик хочет копию со скрытыми размерами до отправки поставщику. PDFium отрисовывает optional content корректно, но в его публичном ABI нет функции перечислить OCG, выбрать конфигурацию или щёлкнуть состоянием слоя. Так что вы спускаетесь на уровень объектов, правите /OCProperties, сохраняете, перезагружаете — а слой всё ещё здесь. Виновата логика видимости PDFium, и её стоит понять, прежде чем трогать хоть один байт

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

Правка массивов /ON и /OFF словаря конфигурации недостаточна, потому что PDFium даёт явному состоянию внутри собственного словаря /Usage OCG победить эти массивы, а правило авто-состояния /AS затем способно переиграть и то и другое. ISO 32000-1 §8.11.4 описывает конфигурации и usage-словари как отдельные механизмы; рендерер PDFium сворачивает их в одно решение, и InspectOptionalContent воспроизводит его в таком порядке:

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

Третий шаг — тот, что обжигает людей. Файл, сохранённый вёрстальным инструментом, часто несёт /ViewState /ON на каждом OCG, и тогда PDFium игнорирует ваш тщательно выправленный массив /OFF: сохранение проходит, файл чисто переоткрывается, а слой всё равно рисуется. Для Print и Export OcExplicitUsageState сначала читает PrintState или ExportState и откатывается к ViewState, когда конкретной записи нет, так что одиночный ViewState /ON пришпиливает слой и для печати. Marked content, ссылающийся на OCMD (§8.11.2.2), затем разрешается против этих погрупповых результатов — через политику /P или, когда есть, выражение видимости /VE

Как перечислить слои, которые PDFium реально покажет?

TPdf.InspectOptionalContent возвращает TPdfOptionalContentInventory, чей массив Groups несёт для каждого OCG номер объекта, имя, intents, три Usage-состояния, язык, диапазон zoom, флаг Locked, индекс radio-группы и вычисленную EffectiveVisible. Метод сначала заставляет PDFium сохранить текущий документ в памяти, разворачивает object streams и сканирует результат, так что правки, сделанные ранее в сессии, отражаются. Конфигурация с индексом 0 — всегда дефолтный словарь /D, а записи /Configs идут с индекса 1; дефолтный аргумент -1 выбирает индекс 0. Документ без /OCProperties заставляет метод вернуть False с причиной в ErrorMessage, а не бросать исключение

procedure TFormMain.ListLayers;
var
  Inv: TPdfOptionalContentInventory;
  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;
  Memo1.Lines.Add(Format('Configuration %d: %s',
    [Inv.SelectedConfigurationIndex,
     string(Inv.Configurations[Inv.SelectedConfigurationIndex].Name)]));
  for G in Inv.Groups do
    Memo1.Lines.Add(Format('obj %d  %s  visible=%s  locked=%s  radio=%d',
      [G.ObjectNumber, string(G.Name),
       BoolToStr(G.EffectiveVisible, True),
       BoolToStr(G.Locked, True), G.RadioGroupIndex]));
end;

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

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

TPdf.SaveAsOptionalContentConfigured принимает массив записей TPdfOptionalContentStateChange (номер объекта группы плюс Visible) и пишет документ, в котором выбранная конфигурация даёт ровно это состояние. Выбранная конфигурация получает /BaseState /ON плюс полные массивы /ON и /OFF, покрывающие каждую группу, а каждый OCG, у которого уже есть Usage-словарь, получает явный ViewState (или PrintState / ExportState, по Options.Usage), совпадающий с его новым состоянием. С TPdfOptionalContentConfigureOptions.Default ключ /AS выбранной конфигурации удаляется, чтобы событие открытия, печати или экспорта не могло вернуть слои назад

procedure TFormMain.SaveWithoutDimensions(DimensionsObj, NotesObj: Integer);
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;

  // Конфигурация 0, ocuView, DisableAutomaticState и EnforceRadioGroups True
  Options := TPdfOptionalContentConfigureOptions.Default;

  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 и trailer. Прежде чем хоть один байт достигнет вашего приёмника, результат переоткрывается в отдельном TPdf под строгой политикой загрузки, и метод проваливается, если таблица перекрёстных ссылок не валидируется. Файловая перегрузка идёт на шаг дальше: она пишет во временный файл рядом с целью и заменяет цель только после успешной верификации, так что отклонённое обновление никогда не оставит после себя полузаписанный чертёж. Это тот же подход верифицированной инкрементальной ревизии, что использует редактор name tree и number tree PDF в PDFium Component

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

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

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

Ворота отказов, которые SaveAsOptionalContentConfigured ставит в PDFium Component перед записью сконфигурированного PDF в Delphi: номер объекта вне OCGs проваливается, заблокированные группы проваливаются, наборы RBGroups с более чем одной видимой участницей отвергаются, зашифрованные документы не могут нести открытые инкрементальные объекты, а подписанные файлы требуют AllowSignedDocument
Каждый отказ случается до того, как тронут приёмник, а причина провала ложится в Report.ErrorMessage вместо того, чтобы оставить после себя полузаписанный чертёж
function TFormMain.SavePrintPreset(Target: TStream;
  const Changes: TPdfOptionalContentStateChanges): Boolean;
var
  Options: TPdfOptionalContentConfigureOptions;
  Report: TPdfOptionalContentConfigureReport;
begin
  Options := TPdfOptionalContentConfigureOptions.Default;
  Options.Usage := ocuPrint;          // пишет /Print << /PrintState ... >>
  Options.ConfigurationIndex := 1;    // первая запись /Configs, не /D
  try
    Result := Pdf.SaveAsOptionalContentConfigured(Target, Changes, Options,
      Report);                        // AllowSignedDocument остаётся False
    if not Result then
      ShowMessage(Report.ErrorMessage);
  except
    on E: EPdfError do
    begin
      ShowMessage(E.Message);         // подписанный файл: в Target не записано ничего
      Result := False;
    end;
  end;
end;

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

Где контроль слоёв встаёт в Delphi-вьюере PDF

Во вьюере контроль слоёв — это чеклист, питаемый инвентарём, плюс перезагрузка сохранённого результата. Заполните чеклист из Groups, задизейбльте записи с Locked, считайте участников с общим RadioGroupIndex взаимоисключающими, а по кнопке применить пишите в TMemoryStream и грузите этот поток обратно в TPdf, чтобы вьюер отрисовал новое состояние. Разводка между TPdf и TPdfView разобрана в статье про сборку насыщенного PDF-вьюера с PDFium VCL в Delphi. Лицензирование, пробные загрузки и остальной набор фич — на странице продукта PDFium Component для Delphi