Artigo Técnico

Redação de PDF em nível de operador em Delphi com PDFiumPas

Alguém pinta uma caixa preta sobre um nome, não achata nada e envia o arquivo, e o revisor seleciona o retângulo e cola o nome em um e-mail. O PDFiumPas responde a isso com redação em nível de operador: o SaveAsRedacted exclui apenas os escalares Unicode cujas caixas de caracteres tocam um retângulo de redação, reconstrói os sobreviventes a partir da fonte, do tamanho, da matriz, do modo de renderização e da cor originais, e recorta trajetos e imagens alinhadas aos eixos em vez de descartá-los por inteiro

Por que um retângulo pintado não é uma redação

Uma operação de desenho adicionada sobre um fluxo de conteúdo não esconde nada, porque os operadores de exibição de texto abaixo dela continuam no fluxo e continuam mapeando para code points. A ISO 32000-1 §9.4 define um objeto de texto como uma sequência de operadores de posicionamento e exibição dentro de BT e ET; um retângulo preenchido desenhado depois é apenas mais um operador no mesmo fluxo. A extração percorre os operadores, não os pixels, então a string coberta volta intacta. A redação real precisa remover o operando, não obscurecer a saída

A implementação segura óbvia é brutal: encontrar todo objeto de página cuja caixa delimitadora intersecta um retângulo de redação e excluir o objeto inteiro. Foi o que as versões anteriores do PDFiumPas faziam, e é correto, mas caro. Um único Tj pode carregar uma linha inteira de tabela, então apagar um número de conta levava junto a data, a descrição e o valor. Um preenchimento retangular que por acaso era uma faixa de tabela de largura total desaparecia da página toda. O logotipo de uma fatura sumia porque a redação cortava um canto dele. A versão 3.101.0 move a decisão um nível abaixo, do objeto de página para o operando

O que a redação em nível de operador realmente exclui?

O PDFiumPas exclui escalares Unicode, não objetos de texto. Durante o SaveAsRedacted, o componente constrói um mapeamento de caractere para objeto de página a partir da página de texto carregada e, para cada caractere pertencente ao objeto em teste, lê a caixa do caractere e intersecta essa caixa com cada retângulo de redação. Os caracteres que tocam um retângulo são marcados para remoção; os demais são marcados como sobreviventes. Se nada intersecta, o objeto fica completamente intacto. Se todos os caracteres intersectam, o objeto é removido por inteiro, exatamente como antes. Só o caso misto dispara uma divisão

Redação em nível de operador do PDFiumPas comparada à exclusão do objeto inteiro em Delphi: o caminho antigo descarta um objeto de texto inteiro quando um número de conta é coberto, enquanto o caminho de divisão exclui apenas os caracteres que intersectam e reemite cada sobrevivente como seu próprio objeto de texto
Só o caso misto dispara uma divisão: nada intersectando deixa o objeto intacto, tudo intersectando o remove por inteiro

Cada sobrevivente é então reemitido como seu próprio objeto de texto, construído a partir do handle de fonte original, do tamanho de fonte original, da matriz de texto por caractere, do modo de renderização de texto original e do estado de fill e stroke do objeto pai, incluindo largura de traço, line join, line cap e dash array. Reutilizar o handle de fonte em vez de resolver um novo é o que mantém os glifos metricamente idênticos, e reutilizar a matriz por caractere é o que mantém kerning e espaçamento de palavras no lugar sem reexecutar o layout. O custo é a contagem de objetos: um caractere retido vira um objeto de texto, e é por isso que o TPdfRedactionOptions.MaxSplitObjects existe como teto rígido para os fragmentos gerados

procedure RedactDocument(const SourcePdf, TargetPdf: string);
var
  Pdf: TPdf;
  Options: TPdfRedactionOptions;
  Report: TPdfRedactionReport;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := SourcePdf;   // o arquivo já contém as anotações /Redact
    Pdf.Active := True;

    Options := TPdfRedactionOptions.Default;
    Options.PreservePartialObjects := True;    // divisão em nível de operador (o padrão)
    Options.RemoveIntersectingAnnotations := True;
    Options.MaxSplitObjects := 20000;          // teto para os fragmentos gerados

    if not Pdf.SaveAsRedacted(TargetPdf, Options, Report) then
      raise Exception.Create(Report.ErrorMessage);   // falha segura, não envie
  finally
    Pdf.Free;
  end;
end;

Retângulos são recortados, geometria rotacionada não

Os trajetos só são divididos quando o PDFiumPas consegue provar que o trajeto é um retângulo alinhado aos eixos. A prova é deliberadamente estreita: a matriz do objeto precisa ter os dois termos de cisalhamento abaixo de 0.0001, o trajeto precisa consistir de quatro a seis segmentos começando com um MOVETO e continuando apenas com LINETO, e os pontos transformados precisam cair nos quatro cantos dos limites do objeto dentro de uma tolerância de 0.01. Um trajeto que passa nessa verificação é reduzido por subtração sucessiva de retângulos, cada retângulo de redação recortando o conjunto sobrevivente em faixas à esquerda, à direita, abaixo e acima, e cada faixa resultante é recriada com o modo de fill, o flag de stroke e o estado de pintura originais. Curvas, triângulos, formas recortadas e qualquer coisa rotacionada falham na verificação e o objeto inteiro é removido

As imagens seguem a ISO 32000-1 §8.9, em que as amostras da imagem ocupam o quadrado unitário mapeado pela matriz de transformação atual. O PDFiumPas inverte esse mapeamento para converter cada fragmento sobrevivente do espaço da página de volta em coordenadas normalizadas da imagem, limita esses valores ao intervalo unitário e então converte em índices de pixels arredondando para dentro: as bordas esquerda e superior passam por Ceil, a direita e a inferior por Floor. Essa direção importa. Arredondar para fora deixaria uma coluna parcial de pixels de origem do lado redigido sobreviver na borda do fragmento. Os limites inteiros de pixels são então convertidos de volta em coordenadas normalizadas e usados para derivar a matriz do fragmento, de modo que o bitmap recortado caia exatamente no limite de pixel em que foi cortado. O recorte em si é uma cópia de linhas consciente de stride pelos formatos Gray, BGR, BGRx e BGRA. Como com os trajetos, uma imagem rotacionada ou inclinada, ou uma cuja matriz tem um termo de escala degenerado, é removida por completo

Como o PDFiumPas recorta uma imagem parcialmente redigida em Delphi: o fragmento sobrevivente do espaço da página é mapeado de volta pelo CTM invertido em coordenadas normalizadas da imagem, limitado ao intervalo unitário e arredondado para dentro para que nenhuma coluna de pixels redigida sobreviva
Ceil à esquerda e em cima, Floor à direita e embaixo, para o corte cair em um limite inteiro de pixel
// Depois de uma chamada bem-sucedida a SaveAsRedacted
Writeln(Format('applied %d redaction(s) on %d page(s)',
  [Report.RedactionCount, Report.RedactedPageCount]));
Writeln(Format('scanned %d object(s), removed %d',
  [Report.ScannedObjectCount, Report.RemovedObjectCount]));
Writeln(Format('split text/path/image: %d / %d / %d',
  [Report.SplitTextObjectCount, Report.SplitPathObjectCount,
   Report.SplitImageObjectCount]));
Writeln(Format('preserved %d fragment(s)', [Report.PreservedFragmentCount]));
Writeln(Format('pruned %d resource name(s), swept %d object(s)',
  [Report.ResourcePruneReport.RemovedNameCount,
   Report.ResourcePruneReport.RemovedObjectCount]));

if Report.PreservedFragmentCount = 0 then
  // nada pôde ser dividido: todo objeto que intersectava foi descartado por inteiro
  LogWholeObjectFallback(SourcePdf);

Por que o PDFiumPas falha de forma segura em caracteres não mapeados?

Porque um glifo que não tem um escalar Unicode reproduzível não pode ser reconstruído com honestidade. Reconstruir um sobrevivente significa chamar a API de definição de texto com uma string, e isso exige um code point estável para cada caractere retido. Fontes de subconjunto simbólicas com dados ToUnicode quebrados ou ausentes podem produzir um mapeamento vazio, e recodificar por adivinhação geraria uma saída que parece correta na tela enquanto carrega um caractere diferente por baixo. O PDFiumPas recusa: a verificação de caracteres retidos levanta uma exceção, a exceção é capturada dentro do SaveAsRedacted, o TPdfRedactionReport.Succeeded volta False com a mensagem em ErrorMessage, e a função retorna False. A mesma regra vale para o orçamento de divisão, que levanta exceção em vez de truncar silenciosamente o conjunto de fragmentos. Quando um documento tem fontes em que você não confia e você quer o comportamento antigo determinístico, defina Options.PreservePartialObjects := False e todo objeto que intersecta desaparece por inteiro

Poda de recursos em escopos compartilhados

Dividir objetos deixa órfãos para trás, e podá-los não é tão simples quanto fazer diff do dicionário /Resources no nível da página. A ISO 32000-1 §7.8.3 permite que o mesmo dicionário de recursos seja referenciado ao mesmo tempo por várias páginas, por Form XObjects, por patterns e por appearance streams de anotações. Excluir um nome de fonte porque uma página parou de usá-lo quebra outra página que ainda usa. O PruneUnusedPdfResources, portanto, funciona por escopo: ele resolve o /Contents seja ele um array direto, uma referência indireta a um array ou um stream único, e depois coleta o uso de recursos a partir dos operadores que realmente nomeiam recursos — Tf para fontes, Do para XObjects, gs para o estado gráfico, CS, cs, SCN e scn para espaços de cor e patterns, sh para shadings, BDC e DP para propriedades de marked-content, além da entrada /CS de imagens inline. Quando um dicionário é compartilhado por vários escopos, os conjuntos de nomes usados são unidos por categoria antes que qualquer coisa seja removida

Poda de recursos no PDFiumPas: três escopos referenciam um mesmo dicionário de recursos compartilhado, seus conjuntos de nomes usados são unidos por categoria, e apenas os nomes que nenhum escopo referencia são removidos antes que os objetos inalcançáveis sejam varridos
Um dicionário pode servir a várias páginas, form XObjects e appearance streams, por isso o PDFiumPas une todos os conjuntos de nomes usados antes de descartar um único nome

Somente nomes confirmados como não referenciados em todos os escopos que apontam para o dicionário são descartados. Um escopo que não pode ser analisado com confiança fica intacto, que é a direção conservadora: um arquivo sem poda é apenas maior, um podado erradamente está corrompido. Os dicionários sobreviventes são gravados de volta como uma atualização incremental esparsa carregando os números de geração exatos, e uma reescrita de alcançabilidade varre então os objetos que se tornaram inalcançáveis quando os nomes desapareceram. O TPdfResourcePruneReport informa ScannedScopeCount, UpdatedScopeCount, RemovedNameCount, RemovedObjectCount, as contagens de bytes e um flag Succeeded. O SaveAsRedacted executa esta etapa automaticamente na saída sanitizada, então o caminho de redação já a inclui, mas a função é exportada no nível de stream para pipelines que a queiram por conta própria

uses
  FPdfCompress;

procedure PruneResourceNames(const SourcePdf, TargetPdf: string);
var
  Source, Dest: TFileStream;
  Report: TPdfResourcePruneReport;
begin
  Source := TFileStream.Create(SourcePdf, fmOpenRead or fmShareDenyWrite);
  try
    Dest := TFileStream.Create(TargetPdf, fmCreate);
    try
      // AllowSignedDocument permanece False: uma reescrita incremental
      // invalidaria os byte ranges que uma assinatura cobre
      PruneUnusedPdfResources(Source, Dest, Report);
      if not Report.Succeeded then
        raise Exception.Create(Report.ErrorMessage);
      Writeln(Format('%d name(s) removed from %d scope(s), %d -> %d bytes',
        [Report.RemovedNameCount, Report.UpdatedScopeCount,
         Report.SourceByteCount, Report.OutputByteCount]));
    finally
      Dest.Free;
    end;
  finally
    Source.Free;
  end;
end;

Integrando isso em um pipeline de documentos

O caminho de redação nunca muta o documento que você carregou. O SaveAsRedacted captura um snapshot isolado, aplica as anotações /Redact nele, remove anexos, executa a passagem de sanitização que remove a open action, as ações do catálogo, as name trees, os arquivos associados, o AcroForm e os metadados, poda recursos e só então grava o stream de saída. Reabrir essa saída como um documento independente e reextrair o texto é a etapa de verificação que vale a pena manter na sua própria suíte de testes, porque é a única verificação que responde à pergunta original — um leitor ainda consegue obter a string. Uma consequência a planejar: a divisão substitui objetos de página, então qualquer handle FPDF_PAGEOBJECT que você estivesse segurando morre depois, a mesma armadilha de tempo de vida descrita em handles de objeto de página obsoletos após uma transformação

Duas peças vizinhas completam o fluxo de trabalho. Decidir onde os retângulos de redação vão geralmente começa pela geometria extraída, e o modelo de blocos e ordem de leitura em blocos de texto estruturado e ordem de leitura é uma fonte melhor de caixas candidatas do que execuções de caracteres brutos. Entregar o resultado a um revisor pertence às regras de endurecimento em construindo uma pré-visualização segura de PDF, em que o preenchimento de formulários e o JavaScript ficam desativados por padrão. Juntas, elas cobrem o ciclo de que a maioria dos fluxos de conformidade precisa: localizar, redigir em nível de operador, verificar reabrindo, pré-visualizar com segurança. A superfície completa da API, o download de avaliação e os termos de licenciamento do componente ficam na página do produto PDFium Delphi Component