Artigo Técnico

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

Alguém pinta uma caixa preta sobre um nome, não achata nada, envia o ficheiro, e o revisor seleciona o retângulo e cola o nome num e-mail. O PDFiumPas responde a isso com redação ao nível de operadores: o SaveAsRedacted apaga apenas os escalares Unicode cujas caixas de caracteres tocam um retângulo de redação, reconstrói os sobreviventes a partir do tipo de letra, tamanho, matriz, modo de renderização e cor originais, e recorta trajetos e imagens alinhados aos eixos em vez de os descartar por inteiro

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

Uma operação de desenho adicionada por cima de um content stream não esconde nada, porque os operadores de exibição de texto por baixo dela continuam no stream e continuam a mapear 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 é simplesmente outro operador no mesmo stream. A extração percorre os operadores, não os pixels, pelo que a string coberta volta intacta. A redação a sério tem de remover o operando, não obscurecer a saída

A implementação segura óbvia é brutal: encontrar cada objeto de página cuja caixa envolvente intersete um retângulo de redação e apagar o objeto inteiro. Foi o que as versões anteriores do PDFiumPas fizeram, e é correto mas caro. Um único Tj pode transportar uma linha inteira de tabela, pelo que tapar um número de conta levou a data, a descrição e o valor consigo. Um preenchimento retangular que por acaso era uma faixa de tabela de largura total desapareceu na página inteira. Um logótipo de fatura desapareceu porque a redação cortou um canto dele. A versão 3.101.0 desce a decisão um nível, do objeto de página para o operando

O que é que a redação ao nível de operadores realmente apaga?

O PDFiumPas apaga escalares Unicode, não objetos de texto. Durante o SaveAsRedacted o componente constrói um mapeamento de caracteres para objetos de página a partir da página de texto carregada, depois, para cada carácter pertencente ao objeto em teste, lê a caixa do carácter e interseta essa caixa contra cada retângulo de redação. Os caracteres que tocam um retângulo são marcados para remoção; o resto é marcado como sobrevivente. Se nada interseta, o objeto fica completamente em paz. Se todos os caracteres intersectam, o objeto é removido por inteiro, exatamente como antes. Só o caso misto desencadeia uma divisão

Redação ao nível de operadores do PDFiumPas comparada com a eliminação de objetos inteiros em Delphi: o caminho antigo deita fora um objeto de texto inteiro quando um número de conta é coberto, enquanto o caminho de divisão apaga apenas os caracteres que intersectam e volta a emitir cada sobrevivente como o seu próprio objeto de texto
Só o caso misto desencadeia uma divisão: nada que interseta deixa o objeto em paz, tudo o que interseta remove-o por inteiro

Cada sobrevivente é depois novamente emitido como o seu próprio objeto de texto, construído a partir do handle do tipo de letra original, do tamanho original do tipo de letra, da matriz de texto por carácter, do modo de renderização de texto original, e do estado de preenchimento e traço do objeto pai, incluindo largura do traço, line join, line cap e dash array. Reutilizar o handle do tipo de letra em vez de resolver um novo é o que mantém os glifos metricamente idênticos, e reutilizar a matriz por carácter é o que mantém o kerning e o espaçamento de palavras no lugar sem correr de novo o layout. O custo é a contagem de objetos: um carácter retido torna-se um objeto de texto, e é por isso que TPdfRedactionOptions.MaxSplitObjects existe como teto rígido dos 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 ficheiro já contém anotações /Redact
    Pdf.Active := True;

    Options := TPdfRedactionOptions.Default;
    Options.PreservePartialObjects := True;    // divisão ao nível de operadores (a predefinição)
    Options.RemoveIntersectingAnnotations := True;
    Options.MaxSplitObjects := 20000;          // teto dos fragmentos gerados

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

Retângulos recortam, geometria rodada 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 tem de ter ambos os termos de cisalhamento abaixo de 0.0001, o trajeto tem de consistir em quatro a seis segmentos começando por um MOVETO e continuando apenas com LINETO, e os pontos transformados têm de cair nos quatro cantos dos limites do objeto dentro de uma tolerância de 0.01. Um trajeto que passa essa verificação é reduzido por subtração sucessiva de retângulos, cada retângulo de redação talhando o conjunto sobrevivente em faixas à esquerda, à direita, abaixo e acima, e cada faixa resultante é recriada com o modo de preenchimento, a flag de traço e o estado de pintura originais. Curvas, triângulos, formas recortadas e tudo o que esteja rodado falham a 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 através da matriz de transformação atual. O PDFiumPas inverte esse mapeamento para converter cada fragmento sobrevivente no espaço da página de volta em coordenadas normalizadas da imagem, prende-as ao intervalo unitário e converte-as em índices de pixel 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 pixel são depois convertidos de volta em coordenadas normalizadas e usados para derivar a matriz do fragmento, pelo que o bitmap recortado cai exatamente na fronteira de pixel onde foi cortado. O recorte em si é uma cópia de linhas consciente do stride pelos formatos Gray, BGR, BGRx e BGRA. Tal como os trajetos, uma imagem rodada ou enviesada, ou cuja matriz tem um termo de escala degenerado, é removida por completo

Como o PDFiumPas recorta uma imagem parcialmente redigida em Delphi: o fragmento sobrevivente no espaço da página é mapeado de volta através da CTM invertida em coordenadas normalizadas da imagem, preso ao intervalo unitário e arredondado para dentro, para que nenhuma coluna de pixels redigida sobreviva
Ceil à esquerda e em cima, Floor à direita e em baixo, para o corte cair numa fronteira de pixel inteira
// 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 podia ser dividido: todos os objetos que intersectavam foram deitados fora por inteiro
  LogWholeObjectFallback(SourcePdf);

Porque é que o PDFiumPas falha de forma segura com caracteres não mapeados?

Porque um glifo sem escalar Unicode reprodutível não pode ser reconstruído honestamente. 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 carácter retido. Tipos de letra de subconjunto simbólico com dados ToUnicode partidos ou ausentes podem produzir um mapeamento vazio, e recodificar por adivinhação geraria uma saída que parece correta no ecrã enquanto transporta um carácter diferente por baixo. O PDFiumPas recusa: a verificação de caracteres retidos lança exceção, a exceção é apanhada dentro do SaveAsRedacted, TPdfRedactionReport.Succeeded volta como False com a mensagem em ErrorMessage, e a função devolve False. A mesma regra aplica-se ao orçamento de divisão, que lança exceção em vez de truncar silenciosamente o conjunto de fragmentos. Quando um documento tem tipos de letra de que não confia e quer o comportamento antigo determinístico, defina Options.PreservePartialObjects := False e todos os objetos que intersectam desaparecem por inteiro

Poda de recursos em âmbitos partilhados

Dividir objetos deixa órfãos para trás, e podá-los não é tão simples como comparar o dicionário /Resources ao nível da página. A ISO 32000-1 §7.8.3 deixa o mesmo dicionário de recursos ser referenciado ao mesmo tempo por várias páginas, por Form XObjects, por padrões e por streams de aparência de anotações. Apagar um nome de tipo de letra porque uma página deixou de o usar quebrará outra página que ainda o usa. O PruneUnusedPdfResources funciona por isso por âmbito: resolve o /Contents quer seja um array direto, uma referência indireta a um array ou um único stream, e depois recolhe a utilização de recursos dos operadores que realmente nomeiam recursos — Tf para tipos de letra, Do para XObjects, gs para estado gráfico, CS, cs, SCN e scn para espaços de cor e padrões, sh para shadings, BDC e DP para propriedades de marked-content, mais a entrada /CS de imagens inline. Quando um dicionário é partilhado por vários âmbitos, os conjuntos de nomes usados são unidos por categoria antes de algo ser removido

Poda de recursos no PDFiumPas: três âmbitos referenciam um dicionário de recursos partilhado, os seus conjuntos de nomes usados são unidos por categoria, e só os nomes que nenhum âmbito referencia são removidos antes de os objetos inalcançáveis serem varridos
Um dicionário pode servir várias páginas, form XObjects e streams de aparência, pelo que o PDFiumPas une todos os conjuntos de nomes usados antes de deitar um único nome fora

Só são deitados fora os nomes confirmados como não referenciados em todos os âmbitos que apontam para o dicionário. Um âmbito que não consiga ser analisado com confiança fica intocado, que é a direção conservadora: um ficheiro não podado é apenas maior, um mal podado está corrompido. Os dicionários sobreviventes são escritos de volta como uma atualização incremental dispersa que transporta os números de geração exatos, e uma reescrita de alcançabilidade varre depois os objetos que se tornaram inalcançáveis assim que os nomes desapareceram. O TPdfResourcePruneReport comunica ScannedScopeCount, UpdatedScopeCount, RemovedNameCount, RemovedObjectCount, as contagens de bytes e uma flag Succeeded. O SaveAsRedacted corre este passo automaticamente na saída saneada, pelo que o caminho de redação já o inclui, mas a função é exportada ao nível de stream para pipelines que a queiram por si

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 continua False: uma reescrita incremental
      // invalidaria os intervalos de bytes 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;

Integrá-lo num pipeline de documentos

O caminho de redação nunca muta o documento que carregou. O SaveAsRedacted captura um instantâneo isolado, aplica aí as anotações /Redact, remove anexos, corre a passagem de saneamento que elimina a ação de abertura, as ações de catálogo, as árvores de nomes, os ficheiros associados, o AcroForm e os metadados, poda recursos, e só então escreve o stream de saída. Reabrir essa saída como um documento independente e reextrair o texto é o passo de verificação que vale a pena manter na sua própria suite de testes, porque é a única verificação que responde à pergunta original — um leitor ainda consegue obter a string. Uma consequência a planear: a divisão substitui objetos de página, pelo que qualquer handle FPDF_PAGEOBJECT que estivesse a segurar está morto depois, a mesma armadilha de tempo de vida descrita em handles de objetos de página obsoletos após uma transformação

Duas peças vizinhas completam o fluxo de trabalho. Decidir onde vão os retângulos de redação costuma começar pela geometria extraída, e o modelo de blocos e ordem de leitura em blocos de texto estruturado e ordem de leitura é uma melhor fonte de caixas candidatas do que corridas de caracteres em bruto. Servir o resultado a um revisor pertence às regras de endurecimento em construir uma pré-visualização segura de PDF, onde o preenchimento de formulários e o JavaScript ficam desligados por predefinição. Em conjunto cobrem o ciclo de que a maioria dos fluxos de conformidade precisa: localizar, redigir ao nível de operadores, verificar reabrindo, pré-visualizar em segurança. A superfície completa da API, a transferência de avaliação e os termos de licenciamento do componente vivem na página do produto PDFium Delphi Component