Artigo Técnico

Adicionar uma camada de texto pesquisável a PDFs digitalizados no Delphi

O PDFium Component adiciona uma camada de texto pesquisável a páginas de PDF digitalizadas a partir do Delphi por meio de ApplyOcrSearchLayer. Ele renderiza cada página selecionada, entrega os pixels a um provedor de OCR fornecido por você e grava as palavras reconhecidas de volta como objetos de texto invisíveis posicionados sobre as palavras na digitalização. A imagem original da página nunca é decodificada, recodificada ou substituída, então o resultado visual é, byte a byte, a mesma página com que você começou

O mecanismo de reconhecimento deliberadamente não faz parte da biblioteca. O PDFium expõe renderização de página, mapeamento de coordenadas, carregamento de fontes, criação de objetos de texto e modos de renderização invisível, mas não contém nenhum mecanismo de OCR, e fingir o contrário significaria empacotar o produto de reconhecimento de outra empresa dentro de um componente de PDF. Em vez disso, o reconhecimento vive atrás da interface IPdfOcrProvider: a biblioteca passa pixels BGRA de layout fixo e origem no topo, e o provedor retorna texto Unicode, valores de confiança e quadriláteros de palavras

O que exatamente é uma camada de texto pesquisável?

Um PDF digitalizado é uma foto de um documento. O conteúdo da página é uma única imagem grande, e não há nada para selecionar, pesquisar, copiar ou indexar. Uma camada de texto pesquisável adiciona objetos de texto reais sobre essa imagem, com o modo de renderização definido como invisível, de modo que os visualizadores não desenham nada, mas a seleção, a busca e a extração encontram as palavras exatamente onde elas aparecem

O posicionamento é o cerne de tudo. Se o texto invisível ficar alguns pontos deslocado, os destaques de seleção caem ao lado das palavras em vez de sobre elas, e copiar um parágrafo produz texto na ordem errada. É por isso que a geometria precisa vir das mesmas transformações que o PDFium usa para renderizar a página, e não de uma estimativa proporcional

Implementando o provedor

O contrato do provedor é um único método. Ele recebe um registro de imagem de página que carrega dimensões, stride, DPI, formato de pixel e os próprios bytes de pixel, além de um token de cancelamento, e retorna palavras ou uma mensagem de erro:

uses
  PDFium;

type
  TMyOcrProvider = class(TInterfacedObject, IPdfOcrProvider)
  public
    function RecognizePage(const Image: TPdfOcrImage;
      const CancellationToken: IPdfCancellationToken;
      out Words: TPdfOcrWords; out ErrorMessage: string): Boolean;
  end;

function TMyOcrProvider.RecognizePage(const Image: TPdfOcrImage;
  const CancellationToken: IPdfCancellationToken;
  out Words: TPdfOcrWords; out ErrorMessage: string): Boolean;
var
  I: Integer;
begin
  // Image.Pixels contém linhas BGRA com origem no topo, de Image.Stride bytes.
  // Entregue-as ao seu mecanismo e depois preencha uma entrada por palavra reconhecida
  SetLength(Words, RecognisedCount);
  for I := 0 to RecognisedCount - 1 do
  begin
    Words[I].Text := EngineWordText(I);
    Words[I].Confidence := EngineWordConfidence(I);   // 0..1
    Words[I].Quad := TPdfOcrQuad.FromRectangle(
      EngineLeft(I), EngineTop(I), EngineRight(I), EngineBottom(I));
  end;
  ErrorMessage := '';
  Result := True;
end;

Quadriláteros em vez de retângulos, porque uma digitalização raramente está perfeitamente alinhada com a página. Uma palavra em uma página levemente rotacionada ocupa um paralelogramo, e TPdfOcrQuad carrega quatro pontos de canto para que palavras inclinadas e rotacionadas mantenham uma região de seleção precisa. Mecanismos que relatam apenas caixas alinhadas aos eixos podem usar FromRectangle, que constrói o quadrilátero degenerado

Por que as posições das palavras não podem ser escalonadas proporcionalmente?

É tentador converter uma coordenada de pixel em uma coordenada de página dividindo pela largura de renderização e multiplicando pela largura da página. Isso só funciona para páginas sem rotação, com uma CropBox idêntica à MediaBox, e com origem em zero, e muitos documentos digitalizados falham em pelo menos uma dessas condições

O PDFium Component mapeia cada um dos quatro cantos do quadrilátero individualmente por meio de FPDF_DeviceToPage, o mesmo mapeamento que o renderizador usou para produzir os pixels, de modo que as entradas /Rotate e caixas de corte deslocadas são tratadas por construção. A matriz afim do objeto de texto é então construída a partir de três dos pontos mapeados, os cantos inferior esquerdo, inferior direito e superior esquerdo, o que é exatamente suficiente para expressar posição, escala, rotação e cisalhamento

O próprio objeto de texto é criado com tamanho de fonte unitário para que seus limites reais de fonte possam ser medidos, e os limites medidos do objeto são então mapeados para o quadrilátero alvo. Dimensionar por um tamanho de ponto estimado e torcer para que combine com a palavra digitalizada oscilaria a cada substituição de fonte; medir primeiro torna o ajuste independente de qual fonte a camada usa

Executando sobre um documento

O registro de opções controla resolução, filtragem e todos os orçamentos. A filtragem por confiança importa mais do que parece: palavras-lixo com baixa confiança poluem permanentemente os resultados de busca, e, ao contrário de uma renderização errada, ninguém percebe até que uma busca retorne besteiras:

var
  Pdf: TPdf;
  Options: TPdfOcrOptions;
  Report: TPdfOcrReport;
  I: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'scanned-contract.pdf';
    Pdf.LoadDocument;

    Options := TPdfOcrOptions.Default;
    Options.Dpi := 300;                  // resolução de reconhecimento
    Options.MinConfidence := 0.60;       // descarta palavras incertas
    Options.SkipPagesWithText := True;   // deixa páginas nativamente digitais intocadas
    Options.ContinueOnError := True;     // uma página ruim não deve parar o trabalho
    Options.MaxPixelsPerPage := 40 * 1000 * 1000;

    if Pdf.ApplyOcrSearchLayer(TMyOcrProvider.Create, Options, Report) then
      Pdf.SaveAs('scanned-contract-searchable.pdf');

    for I := 0 to High(Report.Pages) do
      if Report.Pages[I].Status = popsFailed then
        Writeln(Format('page %d failed: %s',
          [Report.Pages[I].PageNumber, Report.Pages[I].ErrorMessage]));
    Writeln(Format('%d word(s) inserted, %d rejected, %d page(s) skipped',
      [Report.InsertedWordCount, Report.RejectedWordCount,
       Report.SkippedPageCount]));
  finally
    Pdf.Free;
  end;
end;

SkipPagesWithText merece destaque em arquivos mistos. Um PDF que já carrega texto real, seja nativamente digital ou processado anteriormente, ganha uma segunda camada de texto se você rodar o OCR sobre ele às cegas, e a duplicata faz a extração retornar cada palavra duas vezes. O status por página popsSkippedExistingText informa exatamente quais páginas foram deixadas de lado

Orçamentos, cancelamento e contenção de falhas

Toda quantidade que um documento hostil ou simplesmente enorme pode inflar tem um teto: pixels por página e no total, palavras por página e no total, e caracteres por palavra. Todos são verificados antes de a página ser gravada, não depois, e a estimativa de pixels é calculada a partir das dimensões da página e do DPI antes de qualquer bitmap ser alocado. Aumentar o DPI de 150 para 300 quadruplica a memória por página, então o teto por página é o parâmetro a ajustar primeiro quando um job em lote começa a falhar em formatos grandes

O token de cancelamento atravessa todo o caminho: a renderização progressiva, a chamada ao provedor e o loop de inserção por palavra. Isso significa que um usuário que cancela durante o reconhecimento de um arquivo de 400 páginas para dentro de uma única página, em vez de no fim do documento, e o mesmo padrão de token usado em outras partes do componente, descrito em renderização progressiva cancelável, se aplica aqui sem alterações

A contenção de falhas é por página. A biblioteca coleta os identificadores de objeto que inseriu em uma página e chama FPDFPage_GenerateContent uma vez, depois que todas as palavras são posicionadas. Se algo falhar no meio do caminho, seja um erro do provedor ou um problema de fonte, os objetos inseridos naquela página são removidos na ordem inversa e o conteúdo da página é regenerado, de modo que uma página com falha volta ao seu estado original em vez de manter meia camada de texto. O loop do documento então continua ou para, conforme ContinueOnError, e a página ativa é sempre restaurada

Verificando se a imagem realmente ficou intocada

A verificação mais forte disponível também é a mais simples: renderize a página antes e depois de aplicar a camada, no mesmo tamanho, e compare os bitmaps. Eles devem ser idênticos byte a byte, porque texto invisível não desenha nada e o stream de imagem nunca foi decodificado. Qualquer diferença significa que algo além da camada de texto mudou a página

Depois disso, verifique o lado do texto extraindo do arquivo processado e confirmando que as posições das palavras caem sobre a digitalização. O caminho de extração é o mesmo descrito em extração de texto de documentos PDF, e para uma verificação visual rápida do alinhamento, renderizar páginas em imagens, como em conversão de páginas PDF para JPEG, permite sobrepor caixas de palavra na digitalização

Camada de OCR, renderização, extração e edição rodam todas contra o mesmo objeto de documento no Delphi, C++Builder e Lazarus; a superfície completa da API está descrita na página do PDFium Component para Delphi