Artigo Técnico

Alternar camadas de optional content no Delphi com PDFium

Um conjunto de desenhos chega com as cotas dele em uma camada própria, e o jeito óbvio de escondê-las é mover a referência daquele grupo do array /ON da configuração padrão para o /OFF. O arquivo salva e reabre, e as linhas de cota continuam impressas por cima da cartela de título. A causa fica um objeto adiante, dentro do dicionário de grupo: /Usage << /View << /ViewState /ON >> >>, um estado explícito que o renderizador lê antes dos arrays de configuração

O estado de uma camada pode viver em vários lugares em um PDF, e uma edição em /OCProperties toca apenas um deles. O motor PDFium da Google renderiza optional content corretamente, mas a API C pública dele não tem função para enumerar grupos de optional content (OCGs), escolher uma configuração ou ligar e desligar um grupo. Código Delphi que dirige camadas precisa reescrever os objetos por conta própria e reproduzir exatamente a ordem de decisão do renderizador, ou a lista de camadas dele reporta um estado enquanto a página mostra outro

Por que editar /ON e /OFF não muda o que o PDFium renderiza?

Os arrays /ON e /OFF são apenas o segundo de cinco passos, e dois passos posteriores podem sobrepujá-los. A ISO 32000-1 §8.11.4.3 define o dicionário de configuração de optional content com /BaseState, /ON, /OFF, /Locked, /RBGroups e /AS, enquanto a §8.11.4.4 define o dicionário Usage que um OCG pode carregar. Na especificação, Usage descreve para que um grupo serve, e as entradas de auto-estado no /AS transformam essa descrição em ON ou OFF. O PDFium pega um caminho mais curto: quando renderiza para um propósito, o estado explícito do próprio grupo para aquele propósito vence. Dobrados juntos, a ordem fica assim:

  • Parta do /BaseState da configuração, em que /ON e /Unchanged contam como visível e apenas /OFF esconde
  • Aplique o array /ON, depois o array /OFF, então um grupo listado nos dois acaba escondido
  • Aplique o estado Usage explícito do grupo para o propósito pedido, como /Usage << /View << /ViewState /OFF >> >>, que sobrepuja tudo acima
  • Trate um grupo cujo /Intent não contém nem /View nem /All como visível, já que ele não participa da visibilidade de intent de view
  • Rode o array /AS da configuração selecionada por último, deixando entradas para o evento correspondente definirem o estado dos grupos que listam
A decisão de visibilidade em cinco passos que o PDFium Component reproduz para cada grupo de optional content de PDF no Delphi: BaseState define o começo, os arrays ON e OFF da configuração se aplicam em ordem, uma entrada Usage ViewState ou PrintState explícita sobrepuja os dois, não participação por Intent conta como visível, e o array AS roda por último
Editar os arrays ON e OFF não basta porque o PDFium dobra BaseState, os dois arrays, o estado Usage do grupo e por fim as regras de auto-estado AS em um veredito que o InspectOptionalContent reproduz passo a passo

O passo três explica o arquivo da abertura: alguns exportadores gravam /ViewState /ON em todo grupo que criam, e daí em diante nenhuma quantidade de edição de arrays esconde essas camadas. Impressão e exportação seguem a mesma regra com uma torção: o renderizador lê /PrintState ou /ExportState primeiro e volta para o /ViewState quando a entrada específica falta. Um /ViewState /ON solitário portanto prende a camada no papel também. O passo quatro é mais discreto. Um grupo marcado com /Intent /Design está fora da visibilidade de intent de view por completo e pinta independentemente dos arrays, enquanto um grupo sem chave /Intent é tratado como /View, que é o padrão que a especificação dá

Listando camadas e o estado renderizado delas com InspectOptionalContent

O PDFium Component reproduz essa ordem no TPdf.InspectOptionalContent. O método preenche um TPdfOptionalContentInventory e retorna True em caso de sucesso; o parâmetro Usage dele tem padrão ocuView e o parâmetro ConfigurationIndex tem padrão -1, que seleciona o índice 0. O índice de configuração 0 é o dicionário /D padrão, e as entradas de /Configs seguem a partir do índice 1 na ordem do arquivo. Antes de escanear, o método faz o PDFium salvar o documento aberto na memória e expande quaisquer object streams, então edições feitas antes na sessão aparecem no resultado. Um arquivo sem /OCProperties, ou um índice de configuração fora da faixa disponível, faz a chamada retornar False com a razão no ErrorMessage; nenhuma exceção é levantada para nenhum dos casos

procedure TFormMain.ListLayers;
var
  Inv: TPdfOptionalContentInventory;
  Cfg: TPdfOptionalContentConfiguration;
  G: TPdfOptionalContentGroup;
begin
  // Usage tem padrão ocuView; -1 seleciona a configuração 0, o dicionário /D
  if not Pdf.InspectOptionalContent(Inv) then
  begin
    Memo1.Lines.Add('No usable layers: ' + Inv.ErrorMessage);
    Exit;
  end;
  Cfg := Inv.Configurations[Inv.SelectedConfigurationIndex];
  Memo1.Lines.Add(Format('Configuration %d "%s", %d groups, /AS present: %s',
    [Inv.SelectedConfigurationIndex, string(Cfg.Name), Length(Inv.Groups),
     BoolToStr(Cfg.HasAutomaticState, True)]));
  for G in Inv.Groups do
    Memo1.Lines.Add(Format('obj %d  %-24s visible=%s  locked=%s  radio=%d',
      [G.ObjectNumber, string(G.Name),
       BoolToStr(G.EffectiveVisible, True),
       BoolToStr(G.Locked, True), G.RadioGroupIndex]));
end;

Cada TPdfOptionalContentGroup carrega o número de objeto dele, nome, Intents e os três estados explícitos ViewState, PrintState e ExportState, cada um deles ocsUnspecified, ocsOn ou ocsOff. Quando um estado lê ocsOn e o grupo está no array /OFF, você encontrou a armadilha do parágrafo de abertura. Locked e RadioGroupIndex vêm da configuração selecionada, e o RadioGroupIndex é -1 para um grupo que não pertence a nenhum conjunto de /RBGroups

O resto do dicionário Usage é reportado como o arquivo o declara: Language e LanguagePreferred, UserType e UserNames, PageElementSubtype, e MinimumZoom e MaximumZoom atrás de uma flag HasZoomRange. O EffectiveVisible não depende de nenhum deles. Exibição dependente de zoom é tarefa do seu viewer. Cada TPdfOptionalContentConfiguration expõe o nome dele, BaseState, conteúdo dos arrays e RadioGroups, mais OrderGroupObjectNumbers, que achata o /Order em referências de grupo e pula os rótulos de texto de arrays de rótulos aninhados

Impressão e exportação podem discordar da tela

O argumento Usage muda a resposta, e essa é a razão de existir. Sob ocuPrint o inventário lê o /PrintState com o fallback dele para o /ViewState, e apenas entradas /AS cujo /Event é /Print participam. Uma camada de marca d'água construída para aparecer apenas no papel portanto reporta escondida sob ocuView e visível sob ocuPrint. Comparar as duas passadas encontra essas camadas antes que uma impressão surpreenda alguém

procedure TFormMain.ReportPrintOnlyLayers;
var
  OnScreen, OnPaper: TPdfOptionalContentInventory;
  I: Integer;
begin
  if not Pdf.InspectOptionalContent(OnScreen, ocuView) or
     not Pdf.InspectOptionalContent(OnPaper, ocuPrint) then
    Exit;
  // As duas passadas leem o mesmo array /OCGs, então a ordem dos grupos casa
  for I := 0 to High(OnScreen.Groups) do
    if OnScreen.Groups[I].EffectiveVisible <> OnPaper.Groups[I].EffectiveVisible then
      Memo1.Lines.Add(Format('%s: screen=%s, print=%s',
        [string(OnScreen.Groups[I].Name),
         BoolToStr(OnScreen.Groups[I].EffectiveVisible, True),
         BoolToStr(OnPaper.Groups[I].EffectiveVisible, True)]));
end;

Como expressões de visibilidade OCMD são avaliadas?

Um dicionário de membership de optional content (OCMD, ISO 32000-1 §8.11.2.2) liga um pedaço de conteúdo a vários grupos por uma política /P, ou por uma expressão de visibilidade /VE que combina grupos com /And, /Or e /Not. A especificação dá ao /P um padrão de /AnyOn, deixa o /VE tomar precedência sobre /OCGs e /P quando presente, e diz que um OCMD sem grupos não tem efeito na visibilidade

O array Memberships do inventário segue essas regras contra os resultados por grupo da mesma passada, então o usage que você pediu atravessa. Cada membership reporta os números de objeto dos grupos dele, Policy (ocmpAnyOn, ocmpAllOn, ocmpAnyOff, ocmpAllOff, ou ocmpUnknown para um nome fora desses quatro), o texto bruto de VisibilityExpression e o EffectiveVisible dele próprio. Um OCMD sem grupos conta como visível. Uma referência a um número de objeto que não é um OCG conhecido também conta como visível, então uma referência pendurada não esconde tudo ao redor. A avaliação de /VE para em uma profundidade de aninhamento de 32 e trata qualquer coisa mais funda como escondida, então uma expressão autorreferente não consegue estourar a pilha. O scan lista todo OCMD guardado como objeto indireto com /Type /OCMD, object streams incluídos

Gravando um novo estado de camada com SaveAsOptionalContentConfigured

O TPdf.SaveAsOptionalContentConfigured recebe um array de records TPdfOptionalContentStateChange, cada um segurando um GroupObjectNumber e uma flag Visible. Ele grava uma cópia na qual a configuração selecionada produz exatamente aquele estado sob a ordem de cinco passos. Grupos que você não menciona mantêm a visibilidade que o inventário calculou para eles

procedure TFormMain.SaveWithoutDimensions(DimensionsObj, NotesObj: Integer;
  ForPrint: Boolean);
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;

  Options := TPdfOptionalContentConfigureOptions.Default;
  if ForPrint then
  begin
    Options.Usage := ocuPrint;         // grava /Print << /PrintState ... >>
    Options.ConfigurationIndex := 1;   // primeira entrada de /Configs, o /D fica como estava
  end;

  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;

A saída mantém o documento salvo do próprio PDFium como um prefixo byte a byte e anexa apenas o owner de configuração reescrito e os objetos OCG que carregam dicionários Usage, seguidos por uma nova seção de xref e um trailer. Antes de qualquer coisa chegar ao seu destino, o resultado é reaberto em um TPdf separado sob a política de carga estrita, e o método falha se essa carga não tiver sucesso ou se a tabela de referências cruzadas não validar. O overload de arquivo grava em um arquivo temporário ao lado do alvo e substitui o alvo apenas depois da verificação, então uma atualização rejeitada deixa o desenho anterior intocado. É a mesma abordagem de revisão incremental verificada usada pelo editor de name tree e number tree de PDF no PDFium Component. Para confirmar um preset de impressão, recarregue a saída e chame InspectOptionalContent(Inv, ocuPrint, 1); os grupos devem reportar os estados que você passou

Como o SaveAsOptionalContentConfigured no PDFium Component grava um PDF Delphi com camadas alternadas: mudanças de estado e opções entram, a configuração selecionada é reescrita com arrays ON e OFF completos e estados Usage, a revisão incremental verificada é anexada, e uma reabertura estrita precisa validar antes de qualquer coisa ser gravada
O save configurado mantém o re-save do próprio PDFium como prefixo de bytes, anexa o owner de configuração reescrito mais uma nova seção de xref, e reabre o resultado em um TPdf separado antes de o destino ser tocado

O que o save configurado se recusa a fazer?

O save configurado recusa qualquer mudança que o documento proíba ou não consiga carregar com segurança, e toda recusa acontece antes de o destino ser gravado. As checagens rodam em uma ordem fixa. Um documento assinado levanta EPdfError antes de qualquer outra coisa acontecer, a menos que você passe AllowSignedDocument = True. Um documento criptografado é rejeitado em seguida, porque objetos incrementais em texto plano não conseguem carregar o security handler ativo. Então cada mudança pedida é checada: um número de objeto que não está no /OCGs falha de plano, e uma mudança em um grupo listado no array /Locked da configuração falha, embora reafirmar o valor atual dele passe. Por último, com EnforceRadioGroups ligado, um conjunto de /RBGroups que acabaria com mais de um membro visível é rejeitado. Toda recusa exceto a de assinatura retorna False com a razão no Report.ErrorMessage

Os portões de recusa que o SaveAsOptionalContentConfigured aplica no PDFium Component antes de gravar um PDF Delphi configurado: um número de objeto fora dos OCGs falha, grupos travados falham, conjuntos RBGroups com mais de um membro visível são rejeitados, documentos criptografados não conseguem carregar objetos incrementais em texto plano, e arquivos assinados exigem AllowSignedDocument
Toda recusa acontece antes de o destino ser tocado; a razão cai no Report.ErrorMessage, ou na mensagem de EPdfError quando o arquivo é assinado

Dois desses portões vão além do que a especificação estritamente exige. A §8.11.4.3 descreve o /Locked como proteção contra mudanças pela interface de usuário do viewer e descreve grupos de radio button como desligando os outros membros quando um é ligado. O PDFium Component aplica os dois a mudanças programáticas também, e para radio groups ele recusa em vez de escolher membros para desligar. Pedidos dois membros de um conjunto mutuamente exclusivo, a biblioteca não pode saber qual você quis, e um chute contradiria o seu pedido. Desligue os outros no mesmo array Changes e o save passa

O portão de documento assinado existe porque a revisão anexada fica por cima do re-save completo do PDFium, que é uma nova sequência de bytes; a byte range que uma assinatura existente cobre não pode ser presumida de sobreviver. Passe AllowSignedDocument = True apenas quando perder aquela assinatura for aceitável

Três efeitos colaterais importam em um job em lote. A reescrita normaliza a configuração selecionada para /BaseState /ON, então uma linha de base /Unchanged ou /OFF de um autor vira arrays explícitos com a mesma visibilidade resultante. Descartar o /AS também remove truques apenas de impressão, como uma camada de marca d'água que aparece apenas no papel; defina DisableAutomaticState como False para manter essas regras e aceitar que elas podem sobrepujar o estado pedido por você para o evento delas. E os arrays /ON e /OFF são gravados a partir dos estados calculados para o Options.Usage, então um preset de impressão salvo na configuração 0 também muda o que a tela mostra para todo grupo que não tem dicionário Usage. Mantenha presets de impressão em uma entrada de /Configs e deixe o /D para a tela. Do lado da conformidade, PDF/A-2 (ISO 19005-2 cláusula 6.9) e PDF/UA (ISO 14289-1 cláusula 7.10) proíbem /AS em dicionários de configuração, então a saída padrão limpa uma pendência que a sua validação de preflight PDF/A com o PDFium Component reportaria de outro jeito

Aplicando mudanças de camada dentro de um viewer Delphi

Em um viewer, controle de camadas é uma checklist construída a partir do inventário, seguida de um save na memória e uma recarga. Preencha a checklist a partir de Groups, desabilite entradas cuja flag Locked está definida, trate membros que compartilham um RadioGroupIndex como mutuamente exclusivos, e ao aplicar grave para um TMemoryStream pelo overload de stream. O TPdf.LoadDocument espera um componente inativo, então desligue-o primeiro; a carga em buffer copia os bytes, o que deixa você liberar o stream direto

procedure TFormMain.ApplyLayerChecklist(
  const Changes: TPdfOptionalContentStateChanges);
var
  Report: TPdfOptionalContentConfigureReport;
  Output: TMemoryStream;
  KeepPage: Integer;
begin
  Output := TMemoryStream.Create;
  try
    try
      if not Pdf.SaveAsOptionalContentConfigured(Output, Changes,
        TPdfOptionalContentConfigureOptions.Default, Report) then
      begin
        ShowMessage('Layer update rejected: ' + Report.ErrorMessage);
        Exit;
      end;
    except
      on E: EPdfError do
      begin
        ShowMessage(E.Message);       // arquivo assinado: Output continua vazio
        Exit;
      end;
    end;
    KeepPage := Pdf.PageNumber;
    Pdf.Active := False;
    Pdf.LoadDocument(Output);         // Buffered tem padrão True
    Pdf.PageNumber := KeepPage;
  finally
    Output.Free;
  end;
end;

Depois da recarga, o PDFium pinta a partir da configuração nova, então a tela e a próxima chamada de InspectOptionalContent concordam. A fiação entre TPdf e TPdfView é coberta em construir um viewer de PDF cheio de recursos com PDFium VCL no Delphi. Quando um documento ainda não tem camadas e você precisa adicioná-las, criar camadas PDF com OCG e OCMD no Delphi percorre o SaveAsOptionalContentCreated

Precificação, o build de teste e os outros recursos de documento do componente estão listados na página de produto do PDFium Component para Delphi