Artigo Técnico

Reescrever Content Streams de PDF com Segurança em Delphi

O HotPDF percorre todos os operadores do content stream de uma página e entrega cada um deles ao seu código através de VisitLoadedPageContentOperators. Para cada operador, escolhe uma ação: mantê-lo, eliminá-lo, substituí-lo pelos seus próprios bytes, interromper o percurso, ou rejeitar toda a edição. Quando todos os operadores são mantidos, o stream reescrito é idêntico byte a byte ao original, o que é a propriedade que torna o mecanismo seguro de executar sobre documentos que não criou

A edição de content streams é onde as ferramentas de PDF costumam falhar. Um stream é uma sequência de operadores sobre uma máquina de pilha, não uma árvore de documento, e a abordagem ingénua de aplicar uma expressão regular sobre os bytes descodificados quebra no momento em que um literal de string contém algo parecido com um operador, ou uma imagem inline contém binário arbitrário. Um visitante que já tokenizou o stream não tem esse problema

Operadores de content stream de PDF como BT, Tf e TJ a fluir para os dados de evento do visitante, com fase, nome do operador, contagem de operandos, intervalo de bytes e estado de aninhamento, devolvendo depois uma de cinco ações: manter, substituir, eliminar, interromper ou rejeitar
Cada operador visitado produz exatamente uma decisão, e uma passagem que mantém tudo volta a escrever os intervalos intocados, produzindo uma saída idêntica byte a byte

O que se pode realmente fazer com acesso ao nível dos operadores?

Três famílias de tarefas. Auditoria: contar quantas vezes uma página define uma cor fora de um conjunto conhecido, ou encontrar texto desenhado com uma fonte que não está incorporada. Remoção: eliminar uma marca de água que foi desenhada como conteúdo marcado (marked content), ou remover a invocação de uma imagem específica. E normalização: substituir uma forma de operador por um equivalente que um consumidor a jusante trate melhor

Todas partilham um requisito: tem de conseguir deixar intocados os outros 99 por cento do stream. A árvore de sintaxe do HotPDF guarda uma cópia da origem mais intervalos compactos, e as edições são sobreposições ordenadas e não sobrepostas. Ao escrever o resultado, os intervalos intocados são copiados diretamente, e só são emitidos bytes de substituição onde algo foi alterado, pelo que uma ida e volta sem alterações não pode introduzir desvio

O contrato do visitante

Implemente a interface ou atribua o evento, e depois chame o método de visita com ApplyChanges definido como false para uma auditoria ou true para uma reescrita. Os dados do evento trazem a fase, o nome do operador, a sua contagem de operandos, o seu intervalo de bytes, o estado de aninhamento atual e uma vista emprestada para leitura de operandos:

uses
  HPDFDoc, HPDFContentStream;

procedure TAuditor.HandleOperator(Sender: TObject;
  var EventData: THPDFContentOperatorEventData);
var
  Bytes: TBytes;
begin
  if EventData.Phase <> covpOperator then
    Exit;

  // Contar operadores de exibição de texto desenhados dentro de conteúdo marcado
  if (EventData.OperatorName = 'Tj') or
     (EventData.OperatorName = 'TJ') then
  begin
    Inc(FTextOperators);
    if EventData.State.MarkedContentDepth > 0 then
      Inc(FTextInsideMarkedContent);
  end;

  // Ler um operando apenas quando for necessário
  if EventData.OperatorName = 'Do' then
    if EventData.View.GetOperandBytes(0, Bytes) then
      NoteXObjectUse(TEncoding.ANSI.GetString(Bytes));

  EventData.Action := coaKeep;
end;

procedure TAuditor.Run(const FileName: string);
var
  Pdf: THotPDF;
  Stats: THPDFContentOperatorVisitStatistics;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile(FileName) <= 0 then
      Exit;
    Pdf.OnContentOperator := HandleOperator;
    for I := 0 to Pdf.LoadedPageCount - 1 do
      Pdf.VisitLoadedPageContentOperators(I, False, Stats);   // apenas auditoria
    Writeln(Format('%d operators visited, max q/Q depth %d',
      [Stats.VisitedOperatorCount, Stats.MaximumGraphicsStateDepth]));
  finally
    Pdf.Free;
  end;
end;

Porque é que a vista é emprestada, e não mutável?

O callback nunca recebe uma árvore de sintaxe modificável. Recebe uma vista que pode obter os bytes da operação atual ou um operando específico mediante pedido, e nada mais do que isso. Cada alteração passa, em vez disso, pelos campos de ação e de substituição

Essa indireção não é cerimónia sem propósito. A mutação direta da árvore permitiria a um visitante contornar o orçamento de substituição e a verificação de edições sobrepostas, e edições sobrepostas num modelo de sobreposição por intervalos de bytes produzem uma saída silenciosamente errada, em vez de claramente quebrada. Encaminhar as alterações através do campo de ação significa que cada edição é validada da mesma forma, independentemente do que o visitante pretendia

Ler mediante pedido também mantém barato o caso comum. Uma auditoria que apenas conta nomes de operadores nunca chega a materializar bytes de operandos, o que importa num content stream com centenas de milhares de operações

O campo de estado descreve sempre a entrada

Cada evento traz a profundidade do estado gráfico, a profundidade do objeto de texto, a profundidade do conteúdo marcado e a profundidade da secção de compatibilidade tal como estavam na entrada original imediatamente antes de esse operador ser executado. Isto mantém-se verdadeiro mesmo que o seu visitante tenha eliminado ou substituído operadores anteriores

Ancorar o estado à entrada original, em vez de à saída editada, é uma escolha deliberada com um benefício prático: executar a mesma auditoria duas vezes sobre o mesmo documento produz resultados idênticos, e uma auditoria que corre em paralelo com edições não desloca o seu próprio referencial. Se precisar de saber a profundidade na saída, sabe o que eliminou, pelo que a pode calcular; o inverso, reconstruir posições de entrada estáveis a partir de uma saída em movimento, não é possível

Eliminar e substituir, com as regras que o protegem

Uma substituição consiste em bytes brutos de content stream, e é o programador o responsável pela sua sintaxe. As salvaguardas em torno delas são o orçamento sobre o total de bytes de substituição, o requisito de não sobreposição, e a validação opcional da saída, que volta a analisar o resultado antes de este ser aceite:

Salvaguardas de reescrita em Delphi: um visitante que declara a intenção de Action e Replacement, encaminhada através do orçamento de substituição, da verificação de não sobreposição de intervalos de bytes e da validação de saída, até à página guardada e validada
Cada alteração passa pelo campo de ação até às salvaguardas de orçamento, sobreposição e nova análise, pelo que uma validação falhada aborta em vez de escrever operadores danificados
procedure TWatermarkStripper.HandleOperator(Sender: TObject;
  var EventData: THPDFContentOperatorEventData);
begin
  EventData.Action := coaKeep;
  if EventData.Phase <> covpOperator then
    Exit;

  // Eliminar os operadores que pintam o formulário da marca de água
  if (EventData.OperatorName = 'Do') and OperandIsWatermark(EventData) then
    EventData.Action := coaDelete
  // Normalizar uma cor perdida para o cinzento padrão da casa
  else if EventData.OperatorName = 'rg' then
  begin
    EventData.Replacement := TEncoding.ANSI.GetBytes('0.2 0.2 0.2 rg');
    EventData.Action := coaReplace;
  end
  // Recusar toda a edição se encontrarmos algo que não compreendemos
  else if EventData.OperatorName = 'BI' then
    EventData.Action := coaReject;
end;

var
  Options: THPDFContentOperatorVisitOptions;
begin
  Options := THPDFContentOperatorVisitOptions.Default;
  Options.ValidateOutput := True;        // voltar a analisar antes de aceitar
  Options.MaxReplacementBytes := 64 * 1024;

  Pdf.OnContentOperator := HandleOperator;
  if Pdf.VisitLoadedPageContentOperators(PageIndex, True, Options, nil, Stats) then
    if Stats.Modified then
      Pdf.SaveLoadedDocument('cleaned.pdf');
end;

O conjunto de ações inclui duas saídas por uma razão. coaStop termina o percurso e mantém o que já foi decidido até esse ponto, o que é adequado quando já encontrou o que procurava. coaReject abandona toda a visita e deixa a página intocada, o que é adequado quando encontra uma construção que as suas regras de reescrita não cobrem. As imagens inline são o exemplo clássico: a sua carga útil binária não é algo em torno do qual um visitante que normaliza cores deva andar a editar

Ler as estatísticas, e onde o percurso para

O campo de estado distingue conclusão de interrupção, rejeição, entrada inválida, limite excedido, saída inválida e erro do visitante. São problemas diferentes: saída inválida significa que os seus bytes de substituição não voltaram a ser analisados com sucesso, enquanto entrada inválida significa que o stream de origem já estava malformado e nenhuma edição foi tentada

Os contadores tornam uma auditoria auto-verificável. VisitedOperatorCount comparado com KeptOperatorCount, DeletedOperatorCount e ReplacedOperatorCount tem de bater certo, e os valores de profundidade máxima dizem-lhe se a página aninha o suficiente para valer a pena um olhar mais atento. SourceBytes comparado com OutputBytes dá-lhe o efeito de tamanho da sua edição num único número

Reconciliação das estatísticas de auditoria em que as contagens de operadores mantidos, eliminados e substituídos somam a contagem visitada, ao lado de sete finais de estado possíveis, desde concluído até erro do visitante, com uma nota de que as profundidades de estado descrevem a entrada original para que as auditorias se repitam de forma idêntica
Contadores que têm de bater certo, mais um estado que nomeia cada final possível, tornam uma auditoria auto-verificável em documentos que não criou

Para as tarefas comuns existem percursos de nível mais alto que não exigem sequer escrever um visitante. Remover conteúdo de forma permanente, com a limpeza de recursos que uma redação real exige, é abordado em redação e junção de páginas em documentos carregados. Substituir texto visível é abordado em pesquisar e substituir texto num PDF existente. Recorra ao visitante de operadores quando a sua regra for estrutural em vez de textual, ou quando precisar de uma auditoria a que nenhum relatório existente responda

A visita, extração e reescrita de content streams operam sobre o mesmo modelo de documento carregado em Delphi e C++Builder; a lista completa de funcionalidades está disponível na página do componente PDF HotPDF para Delphi