PDFium Component crea nuevas capas PDF desde Delphi mediante TPdf.SaveAsOptionalContentCreated. En un único guardado verificado el método añade grupos de contenido opcional (OCGs), diccionarios de pertenencia de contenido opcional (OCMDs) y dibujo suministrado por el llamador envuelto en Form XObjects cuya entrada /OC apunta a un grupo nuevo, así que PDFium y otros visores muestran u ocultan ese dibujo cuando la capa se conmuta
La petición rara vez es abstracta. Un equipo de ingeniería de plantas quiere dimensiones métricas e imperiales en la misma lámina, con exactamente un conjunto visible. Una imprenta quiere una superposición de línea de corte que se vea en pantalla pero se pueda apagar antes de la prueba. Ninguno de los ficheros tiene capas todavía, así que no hay nada que conmutar. Conmutar capas existentes es otro trabajo, cubierto en conmutar capas de contenido opcional PDF existentes con SaveAsOptionalContentConfigured; este artículo va de crear las capas en primer lugar
Por qué no podéis simplemente poner /OC a un objeto de página con PDFium
PDFium no tiene función pública para adjuntar contenido opcional a un objeto de página existente. Las cabeceras públicas no contienen FPDFPageObj_SetOC ni nada equivalente, así que la ruta habitual de editar y luego FPDFPage_GenerateContent no puede producir contenido con puerta. La puerta tiene que escribirse en la estructura del fichero, y la única pregunta es qué estructura honrará PDFium al renderizar
ISO 32000-1 §8.11.3 da tres formas de hacer gráficos opcionales, y PDFium las maneja de forma distinta:
- Secciones de content stream de página envueltas en marcado de contenido
/OC /Name BDC...EMC, comprobadas por objeto de página durante el renderizado - Form XObjects e image XObjects que llevan una entrada
/OCen su propio diccionario, comprobadas cuando se pinta el XObject - Anotaciones con una entrada
/OCen el diccionario de anotación, que la ruta de renderizado de PDFium no consulta en absoluto, así que una anotación con puerta sigue visible sin importar el estado de la capa
Envolver con marcado de contenido significa reescribir los content streams existentes operador a operador, y las anotaciones quedan descartadas por el renderizador. Eso deja la ruta de Form XObject, y es la que usa PDFium Component: vuestro dibujo entra en un Form XObject nuevo con /OC apuntando al grupo, el form se registra en el diccionario de página /Resources /XObject, y un nuevo content stream que contiene un único /Name Do se añade a /Contents de la página. El contenido existente de la página nunca se parsea ni se reescribe
Describir grupos y forms con puerta en Delphi
TPdf.SaveAsOptionalContentCreated toma tres arrays y devuelve un informe. Ambas sobrecargas, una que escribe a un TStream y otra a un nombre de fichero, comparten la misma lista de parámetros: Groups: TPdfOptionalContentCreateGroups, Memberships: TPdfOptionalContentCreateMemberships, Forms: TPdfOptionalContentCreateForms, out Report: TPdfOptionalContentCreateReport, y un AllowSignedDocument: Boolean = False opcional. El método llegó en PDFium Component v3.154.0
Cada TPdfOptionalContentCreateGroup tiene un Name, un Creator opcional, un Intent de pociView o pociDesign, un flag InitialStateVisible, un flag Locked y un RadioTag. Los grupos que comparten el mismo RadioTag aterrizan en un array /RBGroups, que es como un visor sabe que encender uno apaga a los demás. Cada TPdfOptionalContentCreateForm nombra su grupo de puerta por GroupIndex (un índice zero-based en vuestro array Groups), un PageNumber 1-based, los bytes en crudo del content stream, un bounding box, una matriz opcional y un ResourceName opcional
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 llena los registros con ceros
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;
Fijaos en que el contenido del form es dibujo vectorial puro. El componente escribe solo un diccionario /Resources vacío en cada form, así que el content stream debe ser autosuficiente: colores de dispositivo, trazados, recortes y transformadas funcionan, pero cualquier cosa que necesite un recurso con nombre (una fuente para Tf, una imagen para Do, un ExtGState para gs) no tiene nada contra lo que resolver. Si dejáis ResourceName vacío, los forms se llaman FmOc1, FmOc2 y así; un nombre custom solo puede contener letras, dígitos, guion bajo, guion y punto
Qué añade un OCMD sobre un grupo plano
Un diccionario de pertenencia de contenido opcional hace que la visibilidad dependa de varios grupos a la vez. ISO 32000-1 §8.11.2.2 define dos formas: una lista /OCs con una política /P como /AnyOn o /AllOn, y una expresión de visibilidad /VE construida con /And, /Or y /Not en la especificación. PDFium Component toma un TPdfOptionalContentCreateMembership con MemberGroupIndices, una Policy y una Expression; cuando Expression.GroupIndices no está vacío, gana la expresión y se escribe /VE junto a /OCs
procedure AddUnitMemberships(var Memberships: TPdfOptionalContentCreateMemberships);
begin
SetLength(Memberships, 2);
// Visible cuando cualquiera de las capas de unidades está activa: /OCs [...] /P /AnyOn
SetLength(Memberships[0].MemberGroupIndices, 2);
Memberships[0].MemberGroupIndices[0] := 0;
Memberships[0].MemberGroupIndices[1] := 1;
Memberships[0].Policy := ocmpAnyOn;
// Visible solo mientras la capa imperial está apagada: /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;
El subconjunto soportado es deliberadamente estrecho. Las políticas se limitan a ocmpAllOn y ocmpAnyOn; ocmpAnyOff y ocmpAllOff se rechazan. Las expresiones tienen un nivel de profundidad, con poceAll, poceAny o poceNot sobre grupos creados, y /Not exige exactamente un índice de grupo. poceAll se serializa como /And, poceAny como /Or y poceNot como /Not, los únicos tres operadores que §8.11.2.2 define. Las versiones anteriores a 3.181.2 escribían /All y /Any, que PDFium evalúa como ocultos, así que si creasteis OCMDs con una build anterior, regeneradlos en lugar de fiaros de la expresión. Toda pertenencia necesita además al menos una entrada en MemberGroupIndices, incluso con una expresión presente, porque esa lista se convierte en el array /OCs. Los números de objeto de los diccionarios nuevos vuelven en Report.CreatedMembershipObjectNumbers
Dos comportamientos conviene conocer antes de construir sobre OCMDs. Primero, los forms con puerta de la misma llamada referencian solo grupos: TPdfOptionalContentCreateForm.GroupIndex indexa Groups, no Memberships, así que un form no puede quedar con puerta de un OCMD recién creado por esta API. Segundo, PDFium Component añade cada OCMD nuevo al array /OCProperties /OCGs del catálogo, mientras que /ON, /OFF y /Order se quedan solo para grupos. ISO 32000-1 §8.11.4.2 describe /OCGs como la lista de grupos, pero sin ese registro TPdf.InspectOptionalContent no enumera la pertenencia en absoluto. El efecto lateral es que un inventario de la salida lista cada OCMD como una entrada extra sin nombre en Inventory.Groups, así que emparejad grupos por Name y no por posición
Cómo parchea el componente un /OCProperties existente
TPdf.SaveAsOptionalContentCreated o construye /OCProperties desde cero o extiende el que ya está en el catálogo, y extiende añadiendo, nunca reconstruyendo. Un documento sin capas recibe un diccionario nuevo con /OCGs y una configuración por defecto /D que lleva /ON, /OFF y /Order, más /Locked y /RBGroups cuando algún grupo los pide, como se establece en ISO 32000-1 §8.11.4.3
Para un documento que ya tiene capas, cada array objetivo se parchea insertando las referencias nuevas justo antes de su corchete de cierre. Suena maniático, y está ahí por algo. Un array /Order puede contener arrays anidados y etiquetas de texto que moldean el árbol de capas que muestra un visor, y un enfoque anterior que aplanaba y reconstruía el array perdía esas etiquetas. La inserción al final conserva el árbol existente intacto y simplemente lista los grupos nuevos después. Los arrays que viven en sus propios objetos indirectos se resuelven y parchean en su sitio
Desde v3.175.0 la configuración por defecto en sí también puede ser indirecta. Algunos productores escriben /OCProperties << /OCGs [...] /D 12 0 R >>, y PDFium Component sigue ahora esa referencia y parchea el diccionario objetivo en lugar de fallar con un error de entrada malformada. Los diccionarios de página /Resources directos e indirectos, incluido un subdiccionario /XObject indirecto, se manejan igual
Probar que las capas nuevas conmutan de verdad
Una capa que existe en /OCProperties pero no pone puerta a nada es fácil de producir y difícil de notar. Durante el desarrollo la primera build del writer de forms envolvía un objeto de stream que ya llevaba su propia cabecera N 0 obj y trailer endobj dentro de una segunda cabecera. PDFium no podía parsear el objeto doblado, y en lugar de informar de un error se saltaba silenciosamente el operador Do. El fichero se abría, el panel de capas parecía correcto, y el dibujo con puerta nunca aparecía. Lo que lo destapó fue una sonda que conmutaba la capa y comparaba píxeles renderizados: sin diferencia, así que algo estaba mal
Por eso el método verifica antes de entregar. La salida se reabre mediante una carga estricta que comprueba la tabla de cross-reference, y cada grupo pedido se lee de vuelta mediante InspectOptionalContent para confirmar su nombre, su estado inicial y su registro de bloqueo. Solo entonces se copia la salida a vuestro stream, y la sobrecarga de fichero escribe un fichero temporal y sustituye el objetivo atómicamente. Podéis ejecutar la misma comprobación de conmutación vosotros reabriendo la salida, encontrando el grupo por nombre y guardando una copia con el estado opuesto
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;
Renderizad la página uno de ambos ficheros y las líneas azul y roja deberían intercambiarse. Si no lo hacen, inspeccionad primero el valor EffectiveVisible del grupo en el inventario; refleja el estado que PDFium renderizará de verdad después de aplicar los arrays de configuración y las reglas de uso
Límites y trampas para planificar alrededor
TPdf.SaveAsOptionalContentCreated es un perfil acotado, y la mayoría de sorpresas vienen de tratarlo como un editor de capas general. Las restricciones de abajo se imponen por adelantado, así que una llamada que las viole falla con Report.ErrorMessage relleno y no escribe salida:
- Se exige al menos un grupo; grupos, pertenencias y forms se limitan a 4096 entradas cada uno, los nombres de grupo deben ser únicos y de 1 a 255 caracteres, y cada form acepta como máximo 16 MiB de bytes de contenido
- Los documentos cifrados se rechazan, y un documento firmado necesita
AllowSignedDocument = True; la salida es una reescritura completa, así que las firmas existentes dejarán de validar - Cada entrada de
Formsse convierte en su propio Form XObject, así que poner la misma superposición en 200 páginas guarda el contenido 200 veces en lugar de una - El nuevo stream
Dose añade después del contenido existente de la página, así que corre en el estado gráfico que los streams anteriores dejen; las páginas bien formadas restauran su estado, pero una página conq/Qdesbalanceados puede desplazar vuestra superposición - El contenido existente de la página nunca se mueve a una capa; solo el dibujo que paséis en
Formsrecibe puerta
El punto de repetición es el que hay que sopesar para documentos grandes. Si la superposición es pesada y se repite en cada página, el enfoque de objeto compartido de los sellos de página reutilizables construidos sobre Form XObjects mantiene una copia en el fichero, al coste de manejar la puerta de capa vosotros mismos
FAQ
¿Puede PDFium Component meter contenido existente de página en una capa nueva?
No. TPdf.SaveAsOptionalContentCreated pone puerta solo al contenido que suministréis en Forms; no envuelve operadores existentes en marcado de contenido ni los mueve a un XObject. Para poner en capas arte existente, regeneradlo como contenido de form autosuficiente y quitad el dibujo original en un paso aparte
¿Por qué mis anotaciones con puerta siguen visibles cuando la capa está apagada?
PDFium no comprueba la entrada /OC de los diccionarios de anotación en su ruta de renderizado, así que la puerta a nivel de anotación no tiene efecto en visores basados en PDFium. Esa es la razón por la que PDFium Component adjunta contenido de capa mediante Form XObjects, a los que PDFium sí pone puerta
¿Qué números de objeto debo usar al conmutar las capas nuevas más tarde?
Report.CreatedGroupObjectNumbers contiene los números en el fichero que se acaba de escribir. Cuando reabráis ese fichero y lo configuréis, tomad ObjectNumber de TPdf.InspectOptionalContent y emparejad por Name, que sigue siendo correcto incluso si un guardado posterior renumera objetos
PDFium Component trae SaveAsOptionalContentCreated, SaveAsOptionalContentConfigured y InspectOptionalContent como un único toolkit de contenido opcional para Delphi y C++Builder, construido sobre el renderizador PDFium. Detalles y descargas en la página de producto de PDFium Component