Технічна стаття

Шари необов'язкового вмісту PDF (OCG) у Delphi з PDFium

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

Запит зазвичай приходить від CAD чи GIS контори: комплект креслень їде з розмірами, анотаціями і титульним блоком на окремих шарах, а клієнт хоче копію з прихованими розмірами, перш ніж вона поїде до постачальника. PDFium рендерить необов'язковий вміст коректно, але його публічний 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, як-от /Usage << /View << /ViewState /OFF >> >>, який перекриває все вище
  • Трактуйте групу, в чиєму /Intent немає ані /View, ані /All, як видиму, бо вона не бере участі у видимості view-intent
  • Нарешті запустіть масив /AS обраної конфігурації, чиї записи для відповідної події виставляють стан перелічених груп
П'ятикрокове рішення про видимість, яке PDFium Component відтворює для кожної групи необов'язкового вмісту PDF у Delphi: BaseState ставить старт, масиви ON і OFF конфігурації застосовуються по порядку, явний запис Usage ViewState чи PrintState перекриває обидва, неучасть через Intent рахується видимістю, а масив AS запускається останнім
Правка масивів ON і OFF недостатня, бо PDFium згортає BaseState, обидва масиви, Usage-стан групи і нарешті правила авто-стану AS в один вердикт, який InspectOptionalContent відтворює крок за кроком

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

Як перелічити шари, які PDFium справді покаже?

TPdf.InspectOptionalContent повертає TPdfOptionalContentInventory, масив Groups якого несе для кожного OCG номер об'єкта, ім'я, intents, три Usage-стани, мову, діапазон zoom, прапорець Locked, індекс radio-group і обчислену EffectiveVisible. Метод спершу змушує PDFium зберегти поточний документ у пам'яті, розгортає об'єктні потоки і сканує результат, тож правки, зроблені раніше в сесії, відображені. Індекс конфігурації 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 обраної конфігурації вилучається, щоб подія open, print чи export не могла перевернути шари назад

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 у PDFium Component

Як SaveAsOptionalContentConfigured у PDFium Component пише PDF у Delphi з перемкненими шарами: зміни стану й опції входять, обрана конфігурація переписується з повними масивами ON і OFF та Usage-станами, верифікована інкрементальна ревізія додається, а строге повторне відкриття мусить валідуватися, перш ніж що-небудь буде записано
Налаштоване збереження тримає власний re-save PDFium як байтовий префікс, додає переписаного власника конфігурації плюс нову секцію xref і заново відкриває результат в окремому TPdf, перш ніж торкнутися цілі

Чого налаштоване збереження відмовляється робити?

Налаштоване збереження відмовляє будь-яку зміну, яку сам документ забороняє чи не може безпечно представити, і кожна відмова відбувається до того, як цілю буде торкнуто. Номер об'єкта, якого немає в /OCGs, фейлить одразу. Зміна групи, переліченої в масиві /Locked конфігурації, фейлить, хоча переписування її поточного значення дозволене. З увімкненим EnforceRadioGroups будь-який набір /RBGroups, який закінчив би більш ніж одним видимим членом, відхиляється замість мовчазного вимкнення інших. Зашифровані документи відхиляються, бо інкрементальні об'єкти у відкритому тексті не можуть нести активний обробник безпеки. Підписані документи піднімають 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;

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

Де керування шарами стає до ладу в PDF-глядачі на Delphi

У глядачі керування шарами — це чеклист, що живиться з інвентаря плюс перезавантаженням збереженого результату. Наповніть чеклист із Groups, вимкніть записи, що Locked, трактуйте членів, які ділять RadioGroupIndex, як взаємовиключні, і при застосуванні пишіть у TMemoryStream і завантажуйте той потік назад у TPdf, щоб вид намалював новий стан. Проводку між TPdf і TPdfView покриває стаття про побудову багатофункціонального PDF-глядача на PDFium VCL у Delphi. Ліцензування, trial-завантаження і решта набору функцій — на сторінці продукту PDFium Component for Delphi