PDFium Component создаёт новые PDF-слои из Delphi через TPdf.SaveAsOptionalContentCreated. Одним проверенным сохранением метод добавляет группы optional content (OCG), словари членства optional content (OCMD) и переданную вызывающим графику, завёрнутую в Form XObject, чья запись /OC указывает на новую группу, так что PDFium и прочие вьюверы показывают или прячут ту графику при переключении слоя
Запрос редко бывает абстрактным. Инженерная команда завода хочет метрические и дюймовые размеры на одном чертёжном листе, причём виден ровно один набор. Типография хочет оверлей с линией реза, который виден на экране, но может быть выключен перед спуском. В обоих файлах слоёв пока нет, так что переключать нечего. Переключение существующих слоёв — другая работа, разобранная в статье о переключении существующих слоёв optional content PDF через SaveAsOptionalContentConfigured; эта статья о том, как слои создать с нуля
Почему нельзя просто выставить /OC на объект страницы силами PDFium?
У PDFium нет публичной функции прикрепления optional content к существующему объекту страницы. Публичные заголовки не содержат ни FPDFPageObj_SetOC, ни чего-либо эквивалентного, поэтому обычный маршрут «правь-потом-FPDFPage_GenerateContent» не может произвести запертый контент. Затвор обязан быть вписан в структуру файла, и единственный вопрос — какую структуру PDFium почтит при рендеринге
ISO 32000-1 §8.11.3 даёт три способа сделать графику необязательной, и PDFium обращается с ними по-разному:
- Секции content stream страницы, завёрнутые в marked content
/OC /Name BDC...EMC, проверяются по каждому объекту страницы во время рендеринга - Form XObject и image XObject, несущие запись
/OCв собственном словаре, проверяются при отрисовке XObject - Аннотации с записью
/OCв словаре аннотации путь рендеринга PDFium вообще не консультирует, поэтому запертая аннотация остаётся видимой независимо от состояния слоя
Оборачивание marked content означает переписывание существующих content stream оператор за оператором, а аннотации отсечены рендерером. Остаётся маршрут Form XObject, и его использует PDFium Component: ваша графика уходит в новый Form XObject с /OC на группу, форма регистрируется в словаре /Resources /XObject страницы, а новый content stream с единственным /Name Do дописывается в /Contents страницы. Существующее содержимое страницы никогда не разбирается и не переписывается
Описание групп и запертых форм в Delphi
TPdf.SaveAsOptionalContentCreated принимает три массива и возвращает отчёт. Обе перегрузки — пишущая в TStream и пишущая в имя файла — делят один список параметров: Groups: TPdfOptionalContentCreateGroups, Memberships: TPdfOptionalContentCreateMemberships, Forms: TPdfOptionalContentCreateForms, out Report: TPdfOptionalContentCreateReport и опциональный AllowSignedDocument: Boolean = False. Метод появился в PDFium Component v3.154.0
У каждой записи TPdfOptionalContentCreateGroup есть Name, опциональный Creator, Intent из pociView или pociDesign, флаг InitialStateVisible, флаг Locked и RadioTag. Группы с одинаковым RadioTag попадают в один массив /RBGroups — так вьювер знает, что включение одной выключает остальные. Каждая запись TPdfOptionalContentCreateForm называет свою запирающую группу через GroupIndex (нулебазный индекс в вашем массиве Groups), единицебазный PageNumber, сырые байты content stream, габаритный бокс, опциональную матрицу и опциональный ResourceName
var
Pdf: TPdf;
Groups: TPdfOptionalContentCreateGroups;
Forms: TPdfOptionalContentCreateForms;
Report: TPdfOptionalContentCreateReport;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := 'sheet-a1.pdf';
Pdf.Active := True;
SetLength(Groups, 2); // SetLength заполняет записи нулями
Groups[0].Name := 'Dimensions (mm)';
Groups[0].Intent := pociView;
Groups[0].InitialStateVisible := True;
Groups[0].RadioTag := 'Units';
Groups[1].Name := 'Dimensions (in)';
Groups[1].Intent := pociView;
Groups[1].InitialStateVisible := False;
Groups[1].RadioTag := 'Units';
SetLength(Forms, 2);
Forms[0].GroupIndex := 0;
Forms[0].PageNumber := 1;
Forms[0].Content := TEncoding.ASCII.GetBytes(
'0 0 1 RG 1.5 w 72 120 m 540 120 l S');
Forms[0].BBoxRight := 612;
Forms[0].BBoxTop := 792;
Forms[0].ResourceName := 'DimMetric';
Forms[1] := Forms[0];
Forms[1].GroupIndex := 1;
Forms[1].Content := TEncoding.ASCII.GetBytes(
'1 0 0 RG 1.5 w 72 140 m 540 140 l S');
Forms[1].ResourceName := 'DimImperial';
if not Pdf.SaveAsOptionalContentCreated('sheet-a1-layers.pdf',
Groups, nil, Forms, Report) then
raise Exception.Create(Report.ErrorMessage);
finally
Pdf.Free;
end;
end;
Обратите внимание: содержимое формы — чистая векторная графика. Компонент пишет на каждой форме только пустой словарь /Resources, поэтому content stream обязан быть самодостаточным: Device-цвета, пути, клиппинг и трансформации работают, но всё, что требует именованного ресурса (шрифт для Tf, изображение для Do, ExtGState для gs), не имеет ничего, против чего разрешаться. Если оставить ResourceName пустым, формы называются FmOc1, FmOc2 и так далее; кастомное имя может содержать только буквы, цифры, подчёркивание, минус и точку
Что OCMD добавляет к обычной группе?
Словарь членства optional content делает видимость зависимой от нескольких групп разом. ISO 32000-1 §8.11.2.2 определяет две формы: список /OCs с политикой /P вроде /AnyOn или /AllOn и выражение видимости /VE, собираемое из /And, /Or и /Not по спецификации. PDFium Component принимает запись TPdfOptionalContentCreateMembership с MemberGroupIndices, Policy и Expression; когда Expression.GroupIndices непусто, побеждает выражение, и /VE пишется рядом с /OCs
procedure AddUnitMemberships(var Memberships: TPdfOptionalContentCreateMemberships);
begin
SetLength(Memberships, 2);
// Видимо, когда включён любой слой единиц: /OCs [...] /P /AnyOn
SetLength(Memberships[0].MemberGroupIndices, 2);
Memberships[0].MemberGroupIndices[0] := 0;
Memberships[0].MemberGroupIndices[1] := 1;
Memberships[0].Policy := ocmpAnyOn;
// Видимо, только пока слой дюймов выключен: /VE [ /Not ref ]
SetLength(Memberships[1].MemberGroupIndices, 1);
Memberships[1].MemberGroupIndices[0] := 1;
Memberships[1].Expression.Kind := poceNot;
SetLength(Memberships[1].Expression.GroupIndices, 1);
Memberships[1].Expression.GroupIndices[0] := 1;
end;
Поддерживаемое подмножество нарочно узко. Политики ограничены ocmpAllOn и ocmpAnyOn; ocmpAnyOff и ocmpAllOff отвергаются. Выражения глубиной в один уровень — poceAll, poceAny или poceNot над созданными группами, причём /Not требует ровно один индекс группы. poceAll сериализуется как /And, poceAny как /Or, poceNot как /Not — единственные три оператора, которые §8.11.2.2 определяет. Версии до 3.181.2 писали /All и /Any, которые PDFium вычисляет как скрытые, так что если вы создавали OCMD старой сборкой, перегенерируйте их, а не доверяйте выражению. Каждому членству также нужен минимум один элемент в MemberGroupIndices, даже при наличии выражения, потому что тот список становится массивом /OCs. Номера объектов новых словарей возвращаются в Report.CreatedMembershipObjectNumbers
Два поведения стоит знать, прежде чем строить на OCMD. Первое: запертые формы в том же вызове ссылаются только на группы: TPdfOptionalContentCreateForm.GroupIndex индексирует Groups, а не Memberships, так что форму нельзя запереть свежесозданным OCMD через этот API. Второе: PDFium Component добавляет каждый новый OCMD в массив каталога /OCProperties /OCGs, тогда как /ON, /OFF и /Order остаются групповыми. ISO 32000-1 §8.11.4.2 описывает /OCGs как список групп, но без той регистрации TPdf.InspectOptionalContent вообще не перечисляет членство. Побочный эффект: инвентарь вывода перечисляет каждый OCMD как лишнюю безымянную запись в Inventory.Groups, поэтому сопоставляйте группы по Name, а не по позиции
Как компонент патчит существующий /OCProperties?
TPdf.SaveAsOptionalContentCreated либо строит /OCProperties с нуля, либо расширяет уже лежащий в каталоге, и расширяет дописыванием, никогда перестройкой. Документ без слоёв получает новый словарь с /OCGs и конфигурацией по умолчанию /D, несущей /ON, /OFF и /Order, плюс /Locked и /RBGroups, когда какая-то группа их просит, — как разложено в ISO 32000-1 §8.11.4.3
Для документа, у которого слои уже есть, каждый целевой массив патчится вставкой новых ссылок прямо перед закрывающей скобкой. Звучит дотошно, и на то есть причина. Массив /Order может держать вложенные массивы и текстовые метки, формирующие дерево слоёв, которое показывает вьювер, а более ранний подход, сплющивавший и перестраивавший массив, те метки терял. Вставка в конец хранит существующее дерево целым и просто перечисляет новые группы после него. Массивы, живущие собственными косвенными объектами, разрешаются и патчатся на месте
С v3.175.0 сама конфигурация по умолчанию тоже может быть косвенной. Некоторые продюсеры пишут /OCProperties << /OCGs [...] /D 12 0 R >>, и PDFium Component теперь идёт по той ссылке и патчит целевой словарь, а не проваливается с ошибкой кривой записи. Прямые и косвенные словари /Resources страницы, включая косвенный подсловарь /XObject, обрабатываются так же
Доказательство того, что новые слои реально переключаются
Слой, существующий в /OCProperties, но не запирающий ничего, легко получить и трудно заметить. Во время разработки первая сборка писателя форм оборачивала объект потока, уже несший собственные заголовок N 0 obj и трейлер endobj, во второй заголовок. PDFium не мог разобрать удвоенный объект и вместо сообщения об ошибке молча пропускал оператор Do. Файл открывался, панель слоёв выглядела правильно, а запертая графика так и не появлялась. Вскрыло это зондирование, переключавшее слой и сравнивавшее отрисованные пиксели: разницы нет, значит что-то не так
Потому метод верифицирует перед выдачей. Вывод переоткрывается строгой загрузкой, проверяющей таблицу перекрёстных ссылок, и каждая запрошенная группа перечитывается через InspectOptionalContent для подтверждения имени, начального состояния и регистрации блокировки. Лишь затем вывод копируется в ваш поток, а файловая перегрузка пишет временный файл и атомарно подменяет цель. Тот же тест переключения можно прогнать и самому: переоткройте вывод, найдите группу по имени и сохраните копию с противоположным состоянием
function SaveImperialPreset(const SourceName, TargetName: string): Boolean;
var
Pdf: TPdf;
Inventory: TPdfOptionalContentInventory;
Changes: TPdfOptionalContentStateChanges;
Options: TPdfOptionalContentConfigureOptions;
CfgReport: TPdfOptionalContentConfigureReport;
I: Integer;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := SourceName;
Pdf.Active := True;
if not Pdf.InspectOptionalContent(Inventory) then
raise Exception.Create(Inventory.ErrorMessage);
for I := 0 to High(Inventory.Groups) do
if (Inventory.Groups[I].Name = 'Dimensions (mm)') or
(Inventory.Groups[I].Name = 'Dimensions (in)') then
begin
SetLength(Changes, Length(Changes) + 1);
Changes[High(Changes)].GroupObjectNumber := Inventory.Groups[I].ObjectNumber;
Changes[High(Changes)].Visible := Inventory.Groups[I].Name = 'Dimensions (in)';
end;
Options := TPdfOptionalContentConfigureOptions.Default;
Result := Pdf.SaveAsOptionalContentConfigured(TargetName, Changes,
Options, CfgReport);
finally
Pdf.Free;
end;
end;
Отрендерите первую страницу обоих файлов — синяя и красная линии должны поменяться местами. Если нет, сперва посмотрите значение EffectiveVisible группы в инвентаре; оно отражает состояние, которое PDFium реально отрисует после применения конфигурационных массивов и правил использования
Пределы и ловушки, которые стоит заложить в план
TPdf.SaveAsOptionalContentCreated — ограниченный профиль, и большинство сюрпризов приходит от трактовки его как универсального редактора слоёв. Перечисленные ниже ограничения навязываются заранее, поэтому нарушающий вызов проваливается с выставленным Report.ErrorMessage и не пишет никакого вывода:
- Требуется минимум одна группа; группы, членства и формы ограничены 4096 записей каждая, имена групп обязаны быть уникальными и длиной от 1 до 255 символов, а каждая форма принимает максимум 16 МиБ байтов содержимого
- Зашифрованные документы отвергаются, а подписанному документу нужен
AllowSignedDocument = True; вывод — полная перезапись, поэтому существующие подписи больше не валидируются - Каждая запись
Formsстановится собственным Form XObject, поэтому один и тот же оверлей на 200 страницах хранит содержимое 200 раз, а не один - Новый поток
Doдописывается после существующего содержимого страницы, поэтому он выполняется в том графическом состоянии, которое оставили прежние потоки; хорошо сформированные страницы восстанавливают состояние, но страница с несбалансированной паройq/Qможет сдвинуть ваш оверлей - Существующее содержимое страницы никогда не переносится в слой; запирается только графика, переданная в
Forms
Точка повтора — то, что стоит взвесить для больших документов. Если оверлей тяжёлый и повторяется на каждой странице, подход с разделяемым объектом из статьи о повторно используемых штампах страниц на Form XObject держит одну копию в файле — ценой самостоятельной обработки затвора слоя
FAQ
Может ли PDFium Component уложить существующее содержимое страницы в новый слой?
Нет. TPdf.SaveAsOptionalContentCreated запирает только содержимое, которое вы передаёте в Forms; он не заворачивает существующие операторы в marked content и не переносит их в XObject. Чтобы слоить существующую графику, перегенерируйте её как самодостаточное содержимое формы и уберите исходный рисунок отдельным шагом
Почему мои запертые аннотации всё ещё видимы при выключенном слое?
PDFium не проверяет запись /OC словарей аннотаций на своём пути рендеринга, поэтому затворы уровня аннотаций не действуют во вьюверах на базе PDFium. Именно поэтому PDFium Component прикрепляет содержимое слоя через Form XObject, которые PDFium действительно запирает
Какими номерами объектов пользоваться при последующем переключении новых слоёв?
Report.CreatedGroupObjectNumbers держит номера в файле, только что записанном. Когда вы переоткроете тот файл и сконфигурируете его, берите ObjectNumber из TPdf.InspectOptionalContent и сопоставляйте по Name, что остаётся верным, даже если позднее сохранение перенумерует объекты
PDFium Component поставляет SaveAsOptionalContentCreated, SaveAsOptionalContentConfigured и InspectOptionalContent как единый инструментарий optional content для Delphi и C++Builder, построенный на рендерере PDFium. Детали и загрузки — на странице продукта PDFium Component