Artigo Técnico

Crie camadas PDF (OCG e OCMD) em Delphi com PDFium

O PDFium Component cria camadas PDF novas a partir do Delphi por meio do TPdf.SaveAsOptionalContentCreated. Em um único salvar verificado, o método acrescenta optional content groups (OCGs), dicionários de membership de optional content (OCMDs) e desenho fornecido pelo chamador envolto em Form XObjects cuja entrada /OC aponta para um grupo novo, então o PDFium e outros viewers mostram ou escondem esse desenho quando a camada é alternada

O pedido raramente é abstrato. Uma equipe de engenharia de planta quer dimensões métricas e imperiais na mesma folha de desenho, com exatamente um conjunto visível. Uma gráfica quer um overlay de linha de corte que aparece na tela mas pode ser desligado antes da prova. Nenhum dos arquivos tem camadas ainda, então não há nada para alternar. Alternar camadas existentes é um trabalho diferente, coberto em alternar camadas de optional content PDF existentes com SaveAsOptionalContentConfigured; este artigo trata de criar as camadas em primeiro lugar

Por que não basta definir /OC em um page object com o PDFium?

O PDFium não tem função pública para anexar optional content a um page object existente. Os headers públicos não contêm nenhum FPDFPageObj_SetOC ou equivalente, então a rota usual de editar e depois FPDFPage_GenerateContent não consegue produzir conteúdo travado. O portão precisa ser gravado na estrutura do arquivo, e a única pergunta é qual estrutura o PDFium vai honrar ao renderizar

A ISO 32000-1 §8.11.3 dá três formas de tornar gráficos opcionais, e o PDFium as trata de forma diferente:

  • Seções de content stream da página envoltas em marked content /OC /Name BDC ... EMC, conferidas por page object durante a renderização
  • Form XObjects e image XObjects que carregam uma entrada /OC em seu próprio dicionário, conferidas quando o XObject é pintado
  • Anotações com uma entrada /OC no dicionário de anotação, que o caminho de renderização do PDFium não consulta de forma alguma, então uma anotação travada continua visível independentemente do estado da camada

Envolver com marked content significa regravar content streams existentes operador por operador, e anotações ficam descartadas pelo renderer. Isso deixa a rota de Form XObject, e é a que o PDFium Component usa: seu desenho vai para um Form XObject novo com /OC apontando para o grupo, o form é registrado no dicionário /Resources /XObject da página, e um novo content stream contendo um único /Name Do é anexado ao /Contents da página. O conteúdo existente da página jamais é interpretado ou regravado

Rotas de travamento de optional content da ISO 32000-1 sob o PDFium: marked content entre OC BDC e EMC é conferido por page object, um Form XObject com entrada /OC é conferido quando pintado e é a rota pela qual o PDFium Component grava do Delphi, enquanto /OC de anotação jamais é consultado
O PDFium confere optional content para seções de marked content e dicionários de XObject, mas não para anotações, e é por isso que o SaveAsOptionalContentCreated trava o desenho fornecido por meio de um Form XObject e um operador Do

Descrevendo grupos e forms travados em Delphi

O TPdf.SaveAsOptionalContentCreated recebe três arrays e devolve um relatório. Ambas as sobrecargas, uma gravando em um TStream e outra em um nome de arquivo, compartilham a mesma lista de parâmetros: Groups: TPdfOptionalContentCreateGroups, Memberships: TPdfOptionalContentCreateMemberships, Forms: TPdfOptionalContentCreateForms, out Report: TPdfOptionalContentCreateReport, e um opcional AllowSignedDocument: Boolean = False. O método chegou no PDFium Component v3.154.0

Cada TPdfOptionalContentCreateGroup tem um Name, um Creator opcional, um Intent de pociView ou pociDesign, uma flag InitialStateVisible, uma flag Locked e um RadioTag. Grupos que compartilham o mesmo RadioTag caem em um array /RBGroups, que é como um viewer sabe que ligar um desliga os outros. Cada TPdfOptionalContentCreateForm nomeia seu grupo de travamento por GroupIndex (um índice base zero no seu array Groups), um PageNumber base 1, os bytes brutos do content stream, uma bounding box, uma matriz opcional e um 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 preenche os registros com zeros
    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;

Note que o conteúdo do form é desenho vetorial puro. O componente grava apenas um dicionário /Resources vazio em cada form, então o content stream precisa ser autossuficiente: cores de dispositivo, paths, clipping e transformações funcionam, mas qualquer coisa que precise de um recurso nomeado (uma fonte para Tf, uma imagem para Do, um ExtGState para gs) não tem nada contra o que resolver. Se você deixar o ResourceName vazio, os forms são nomeados FmOc1, FmOc2 e assim por diante; um nome customizado pode conter apenas letras, dígitos, underscore, menos e ponto

O que um OCMD acrescenta sobre um grupo simples?

Um dicionário de membership de optional content faz a visibilidade depender de vários grupos ao mesmo tempo. A ISO 32000-1 §8.11.2.2 define duas formas: uma lista /OCs com uma política /P como /AnyOn ou /AllOn, e uma expressão de visibilidade /VE construída com /And, /Or e /Not na especificação. O PDFium Component recebe um TPdfOptionalContentCreateMembership com MemberGroupIndices, um Policy e um Expression; quando Expression.GroupIndices não está vazio, a expressão vence e o /VE é gravado ao lado do /OCs

procedure AddUnitMemberships(var Memberships: TPdfOptionalContentCreateMemberships);
begin
  SetLength(Memberships, 2);

  // Visível quando qualquer camada de unidade está ligada: /OCs [...] /P /AnyOn
  SetLength(Memberships[0].MemberGroupIndices, 2);
  Memberships[0].MemberGroupIndices[0] := 0;
  Memberships[0].MemberGroupIndices[1] := 1;
  Memberships[0].Policy := ocmpAnyOn;

  // Visível apenas enquanto a camada imperial está desligada: /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;

O subset suportado é deliberadamente estreito. As políticas se limitam a ocmpAllOn e ocmpAnyOn; ocmpAnyOff e ocmpAllOff são rejeitados. As expressões têm um nível de profundidade, com poceAll, poceAny ou poceNot sobre grupos criados, e o /Not exige exatamente um índice de grupo. poceAll serializa como /And, poceAny como /Or e poceNot como /Not, os únicos três operadores que a §8.11.2.2 define. Versões antes da 3.181.2 gravavam /All e /Any, que o PDFium avalia como oculto, então se você criou OCMDs com uma build mais antiga, regenere-os em vez de confiar na expressão. Todo membership também precisa de ao menos uma entrada em MemberGroupIndices, mesmo quando uma expressão está presente, porque aquela lista vira o array /OCs. Os números de objeto dos novos dicionários voltam em Report.CreatedMembershipObjectNumbers

Dicionários de membership de optional content criados pelo PDFium Component em Delphi: uma lista /OCs com política /AnyOn ou /AllOn avalia estados de grupo juntos, enquanto uma expressão de visibilidade /VE como /Not sobre um grupo trava conteúdo com lógica And, Or e Not de um nível de profundidade
Políticas avaliam a lista /OCs enquanto expressões /VE combinam grupos com /And, /Or e /Not - de qualquer forma o membership precisa de ao menos um grupo membro para referenciar

Dois comportamentos valem conhecer antes de construir sobre OCMDs. Primeiro, os forms travados na mesma chamada referenciam apenas grupos: o TPdfOptionalContentCreateForm.GroupIndex indexa Groups, não Memberships, então um form não pode ser travado por um OCMD recém-criado por esta API. Segundo, o PDFium Component acrescenta cada OCMD novo ao array /OCProperties /OCGs do catálogo, enquanto /ON, /OFF e /Order continuam exclusivos de grupos. A ISO 32000-1 §8.11.4.2 descreve o /OCGs como a lista de grupos, mas sem esse registro o TPdf.InspectOptionalContent não enumera o membership de forma alguma. O efeito colateral é que um inventário da saída lista cada OCMD como uma entrada extra sem nome em Inventory.Groups, então case grupos pelo Name em vez de pela posição

Como o componente remenda um /OCProperties existente?

O TPdf.SaveAsOptionalContentCreated ou constrói o /OCProperties do zero ou estende o que já está no catálogo, e estende anexando, jamais reconstruindo. Um documento sem camadas recebe um dicionário novo com /OCGs e uma configuração padrão /D carregando /ON, /OFF e /Order, mais /Locked e /RBGroups quando algum grupo os pedir, como disposto na ISO 32000-1 §8.11.4.3

Para um documento que já tem camadas, todo array alvo é remendado inserindo as novas referências logo antes do colchete de fechamento. Isso parece frescurite, e está ali por uma razão. Um array /Order pode guardar arrays aninhados e rótulos de texto que moldam a árvore de camadas que um viewer mostra, e uma abordagem anterior que achatava e reconstruía o array perdia esses rótulos. A inserção no fim mantém a árvore existente intacta e simplesmente lista os grupos novos depois dela. Arrays que vivem em seus próprios objetos indiretos são resolvidos e remendados no lugar

Estendendo um dicionário /OCProperties existente com o PDFium Component em Delphi: novas referências de optional content group são inseridas logo antes do colchete de fechamento para que arrays /Order aninhados e rótulos de texto sobrevivam, e uma configuração padrão /D indireta é seguida e remendada no lugar
Anexar referências antes do colchete de fechamento mantém uma árvore /Order existente intacta, o que uma reconstrução que achata arrays aninhados e rótulos não pode garantir

Desde a v3.175.0 a configuração padrão em si também pode ser indireta. Alguns produtores gravam /OCProperties << /OCGs [...] /D 12 0 R >>, e o PDFium Component agora segue essa referência e remenda o dicionário alvo em vez de falhar com um erro de entrada malformada. Dicionários /Resources de página diretos e indiretos, incluindo um subdicionário /XObject indireto, são tratados da mesma forma

Provando que as camadas novas realmente alternam

Uma camada que existe no /OCProperties mas não trava nada é fácil de produzir e difícil de notar. Durante o desenvolvimento, a primeira build do writer de forms envolvia um objeto de stream que já carregava seu próprio header N 0 obj e trailer endobj dentro de um segundo header. O PDFium não conseguia interpretar o objeto duplicado, e em vez de reportar um erro silenciosamente pulava o operador Do. O arquivo abria, o painel de camadas parecia certo, e o desenho travado nunca aparecia. O que o expôs foi uma sonda que alternava a camada e comparava pixels renderizados: nenhuma diferença, então algo estava errado

É por isso que o método verifica antes de entregar. A saída é reaberta por um load estrito que confere a tabela de cross-reference, e todo grupo pedido é lido de volta pelo InspectOptionalContent para confirmar seu nome, seu estado inicial e seu registro de bloqueio. Só então a saída é copiada para o seu stream, e a sobrecarga de arquivo grava um arquivo temporário e substitui o alvo atomicamente. Você pode rodar a mesma checagem de alternância você mesmo reabrindo a saída, achando o grupo pelo nome e salvando uma cópia com o estado oposto

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;

Renderize a página um de ambos os arquivos e as linhas azul e vermelha devem se alternar. Se não se alternarem, inspecione primeiro o valor de EffectiveVisible do grupo no inventário; ele reflete o estado que o PDFium de fato vai renderizar depois que arrays de configuração e regras de uso forem aplicados

Limites e armadilhas para planejar em volta

O TPdf.SaveAsOptionalContentCreated é um perfil limitado, e a maioria das surpresas vem de tratá-lo como um editor de camadas geral. As restrições abaixo são impostas de cara, então uma chamada que as viole falha com Report.ErrorMessage preenchido e não grava saída:

  • Ao menos um grupo é exigido; grupos, memberships e forms têm teto de 4096 entradas cada, nomes de grupo precisam ser únicos e ter 1 a 255 caracteres, e cada form aceita no máximo 16 MiB de bytes de conteúdo
  • Documentos criptografados são rejeitados, e um documento assinado precisa de AllowSignedDocument = True; a saída é uma regravação completa, então assinaturas existentes deixarão de validar
  • Cada entrada de Forms vira seu próprio Form XObject, então pôr o mesmo overlay em 200 páginas armazena o conteúdo 200 vezes em vez de uma
  • O novo stream de Do é anexado depois do conteúdo existente da página, então roda no estado gráfico que os streams anteriores deixarem; páginas bem-formadas restauram seu estado, mas uma página com q/Q desbalanceados pode deslocar seu overlay
  • O conteúdo existente da página jamais é movido para uma camada; apenas o desenho que você passar em Forms é travado

O ponto de repetição é o que pesar para documentos grandes. Se o overlay é pesado e repete em toda página, a abordagem de objeto compartilhado dos stamps de página reutilizáveis construídos sobre Form XObjects mantém uma única cópia no arquivo, ao custo de tratar o portão de camada você mesmo

FAQ

O PDFium Component pode pôr conteúdo de página existente em uma camada nova?

Não. O TPdf.SaveAsOptionalContentCreated trava apenas o conteúdo que você fornecer em Forms; ele não envolve operadores existentes em marked content nem os move para um XObject. Para colocar em camadas arte existente, regenere-a como conteúdo de form autossuficiente e remova o desenho original em um passo separado

Por que minhas anotações travadas continuam visíveis quando a camada está desligada?

O PDFium não confere a entrada /OC de dicionários de anotação em seu caminho de renderização, então travamento em nível de anotação não tem efeito em viewers baseados no PDFium. Essa é a razão de o PDFium Component anexar conteúdo de camada por meio de Form XObjects, que o PDFium de fato trava

Que números de objeto devo usar ao alternar as camadas novas depois?

O Report.CreatedGroupObjectNumbers guarda os números no arquivo que acabou de ser gravado. Quando você reabrir esse arquivo e configurá-lo, tome o ObjectNumber do TPdf.InspectOptionalContent e case pelo Name, que continua correto mesmo se um salvar posterior renumerar objetos

O PDFium Component entrega SaveAsOptionalContentCreated, SaveAsOptionalContentConfigured e InspectOptionalContent como um toolkit único de optional content para Delphi e C++Builder, construído sobre o renderer PDFium. Detalhes e downloads estão na página do produto PDFium Component