Artículo técnico

Crear capas PDF (OCG y OCMD) en Delphi con PDFium

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 /OC en su propio diccionario, comprobadas cuando se pinta el XObject
  • Anotaciones con una entrada /OC en 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

Rutas de puerta de contenido opcional de ISO 32000-1 bajo PDFium: el marcado de contenido entre OC BDC y EMC se comprueba por objeto de página, un Form XObject con entrada /OC se comprueba al pintarse y es la ruta desde la que escribe PDFium Component en Delphi, mientras el /OC de anotaciones nunca se consulta
PDFium comprueba el contenido opcional de las secciones de marcado de contenido y los diccionarios XObject pero no el de las anotaciones, por eso SaveAsOptionalContentCreated pone puerta al dibujo suministrado mediante un Form XObject y un operador Do

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

Diccionarios de pertenencia de contenido opcional creados por PDFium Component en Delphi: una lista /OCs con política /AnyOn o /AllOn evalúa los estados de grupo juntos, mientras una expresión de visibilidad /VE como /Not sobre un grupo pone puerta al contenido con lógica And, Or y Not de un nivel
Las políticas evalúan la lista /OCs mientras las expresiones /VE combinan grupos con /And, /Or y /Not - en cualquier caso la pertenencia necesita al menos un grupo miembro al que referenciar

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

Extensión de un diccionario /OCProperties existente con PDFium Component en Delphi: las referencias de grupos de contenido opcional nuevas se insertan justo antes del corchete de cierre para que los arrays /Order anidados y las etiquetas de texto sobrevivan, y una configuración por defecto /D indirecta se sigue y se parchea en su sitio
Añadir referencias antes del corchete de cierre conserva intacto un árbol /Order existente, algo que una reconstrucción que aplana arrays y etiquetas anidados no puede garantizar

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 Forms se 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 Do se 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 con q/Q desbalanceados 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 Forms recibe 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