Extrair o texto de uma página é a metade fácil do problema. No momento em que um usuário digita uma palavra em uma caixa de pesquisa e espera que o visualizador salte para ela e desenhe uma caixa amarela ao seu redor, você precisa de algo que a string de texto simples não pode fornecer: a página em que cada correspondência se encontra e o retângulo que ela ocupa nas coordenadas do PDF. Uma string concatenada ao longo de uma página perdeu essa geometria. Você pode encontrar a substring, mas não pode apontar para ela
O PDFlibPas é uma biblioteca PDF em Object Pascal nativa para Delphi e C++Builder, e a partir da versão 3.78.0 ele responde exatamente a essa pergunta. Três APIs de consulta estão posicionadas no topo do extrator de blocos de texto existente: o método SearchText percorre um intervalo de páginas e retorna cada correspondência com sua página e retângulo alinhado ao eixo, o EnumPageElements lista tudo em uma página (blocos de texto e imagens incorporadas), e o GetTextInAreaEx relata o retângulo de cada bloco dentro de uma região, em vez de achatá-los em uma lista de strings. Nenhum deles afeta o caminho de gravação; são adições puras no lado de leitura sobre o mecanismo que a biblioteca já possuía
Por que a geometria reside na lista de blocos de texto, e não no funil
O instinto natural é reutilizar o que quer que o método GetPageText execute internamente. Esse caminho passa por um "funil" de extração transitório que produz the string da página e depois se libera antes que a chamada retorne. No momento em que você obtém o resultado, as coordenadas de cada bloco já se foram. Elas nunca foram suas para guardar
As coordenadas sobrevivem em uma estrutura diferente. A função ExtractPageTextBlocks(3) retorna um identificador de lista de blocos de texto cujos itens carregam, cada um, um quadrilátero delimitador (bounding quad) de oito doubles, um nome de fonte, um tamanho de fonte e o texto do bloco. Esse identificador é o único local onde a geometria é mantida após a extração, razão pela qual cada uma das novas APIs de consulta é construída sobre ele em vez de no funil. Reutilizar a lista de blocos significa que a pesquisa, a enumeração e as consultas de região compartilham uma única passagem de extração e uma única definição de onde um bloco está
Portanto, o formato do SearchText decorre dessa restrição. Para cada página no intervalo, ele extrai a lista de blocos, lê o texto de cada bloco com GetTextBlockText, testa-o contra a consulta e, para os blocos que correspondem, reduz o quadrilátero a um retângulo. A correspondência retornada é um pequeno registro:
type
TPDFlibSearchHit = record
Page: Integer; // 1-based page of the match
Left, Top, Right, Bottom: Double; // axis-aligned hit rectangle
MatchText: WideString; // the block text that contained the query
end;
O array de limites é intercalado em X/Y, e não em quatro cantos
Este é o detalhe que traz problemas primeiro. O método GetTextBlockBound(ListID, Index, BoundIndex) recebe um BoundIndex de 1 a 8, e esses oito valores não são "canto 1, canto 2, canto 3, canto 4" com dois campos agrupados da forma que você poderia imaginar. Eles são X, Y, X, Y, X, Y, X, Y: os índices ímpares são coordenadas X, os índices pares são coordenadas Y, totalizando quatro pontos. Leia-os no emparelhamento errado e seu retângulo será um absurdo
A razão para existir um quadrilátero (quad), em vez de um retângulo simples, é a rotação. Um bloco de texto definido em um ângulo possui um polígono delimitador de quatro pontos real, e los oito doubles o descrevem com fidelidade. Para o caso de uso de destaque e salto (highlight-and-jump), você quase sempre deseja uma caixa vertical em vez disso, de modo que a biblioteca reduz o quadrilátero a um retângulo alinhado aos eixos varrendo os quatro pontos para obter os valores mínimos e máximos de X e Y. O texto rotacionado colapsa para a caixa vertical que o envolve, que é o que uma sobreposição de destaque necessita:
var
Pdf: TPDFlib;
Hits: array[0..255] of TPDFlibSearchHit;
Found, I: Integer;
begin
Pdf := TPDFlib.Create(nil);
try
Pdf.LoadFromFile('contract.pdf', '');
// Search pages 1 to 10, case-insensitive, substring match.
Found := Pdf.SearchText('indemnity', [], '1-10', Hits);
for I := 0 to Found - 1 do
if I <= High(Hits) then
WriteLn(Format('p%d: [%.1f %.1f %.1f %.1f] %s',
[Hits[I].Page, Hits[I].Left, Hits[I].Top,
Hits[I].Right, Hits[I].Bottom, Hits[I].MatchText]));
finally
Pdf.Free;
end;
end;
Observe que o retângulo está nas coordenadas de espaço do usuário do PDF com a origem no canto inferior esquerdo da página, o mesmo sistema de coordenadas que você passa para chamadas de desenho e anotação. Isso é deliberado: o retângulo que você recebe de volta de uma correspondência de pesquisa é o retângulo que você pode passar direto para uma anotação de destaque ou um comando de "rolar até aqui" sem precisar converter nada
Diferenciação de maiúsculas/minúsculas, palavras inteiras e onde o CJK difere
O segundo parâmetro é um conjunto TPDFlibSearchOptions composto por soCaseSensitive e soWholeWord. O conjunto vazio [] é o caso comum: uma pesquisa de substring insensível a maiúsculas/minúsculas. Adicione soCaseSensitive para diferenciar Indemnity de indemnity, adicione soWholeWord para impedir que sign corresponda dentro de signature, ou combine ambos
A correspondência de palavra inteira precisa de uma definição do que é um limite de palavra, e aqui a regra merece ser declarada claramente porque ela é centrada em ASCII por design. Um caractere conta como parte de uma palavra quando é uma letra ASCII, um dígito ASCII ou um sublinhado (underscore): a classe [A-Za-z0-9_] familiar das regras de identificadores. Uma correspondência se qualifica como palavra inteira apenas quando os caracteres imediatamente antes e depois dela não são caracteres de palavra (ou a correspondência está na borda do bloco)
A consequência para escritas não latinas é algo a se saber antes de implementar uma caixa de pesquisa multilíngue. Como os caracteres Han, kana e outras letras não ASCII ficam fora dessa classe, cada limite próximo a eles é lido como uma borda de não-palavra. Na prática, isso significa que a pesquisa de palavra inteira em texto CJK se comporta como se cada posição fosse um limite de palavra válido, de modo que a flag efetivamente se degrada para correspondência de substring nessa situação. Essa é uma limitação documentada, não um bug, e corresponde ao comportamento no qual o recurso foi modelado. Se o seu corpus for principalmente CJK, o modo de palavra inteira não fornecerá a segmentação que um tokenizador dedicado ofereceria; planeje seu fluxo em torno disso em vez de depender dele
Uma nota de rodapé de implementação que explica uma classe de falhas sutis em outros locais: a comparação insensível a maiúsculas/minúsculas usa UpperCase na WideString, não AnsiUpperCase. A variante Ansi retorna uma AnsiString, que não se alinharia com a WideString que o resto do caminho utiliza, e misturar as duas produz incompatibilidades de tipo e, pior, perda de mapeamento de caracteres fora da página de código ativa. Unicode na entrada, Unicode na saída, de ponta a ponta
Um analisador de intervalo de páginas para toda a biblioteca
O terceiro parâmetro é uma string de intervalo de páginas, como "1,3,5-9". Não há nada customizado sobre como ele é analisado: o mesmo PLParsePageRangeList que dá suporte ao PrintPages e às rotinas de cópia de página também lida com isso aqui, de modo que um intervalo que imprime corretamente também é pesquisado corretamente. Uma string de intervalo vazia é a sentinela para "todas as páginas", caso em que a função SearchText constrói a lista completa por si mesma
O escopo é importante para o custo. Pesquisar um trecho de dez páginas de um documento de mil páginas extrai blocos para dez páginas, e não mil, porque o loop seleciona e extrai apenas as páginas nomeadas pelo intervalo. Quando você já sabe que uma cláusula reside no apêndice, declare isso no intervalo e pule o restante do arquivo
Internamente, tanto a pesquisa quanto a enumeração alteram a página selecionada conforme iteram, de modo que cada uma delas salva a página selecionada pelo chamador na entrada e a restaura em um bloco finally. Chame SearchText no meio da construção de uma página e sua seleção estará exatamente onde você a deixou quando a chamada retornar. Esse contrato de salvar-e-restaurar é o tipo de coisa que você só nota quando está faltando, razão pela qual ele está presente
Enumerando uma página inteira: texto e imagens em uma única lista
A pesquisa responde "onde está esta palavra". A outra metade da introspecção é "o que realmente está nesta página", e isso é o que faz o EnumPageElements. Ele retorna uma lista unificada onde cada elemento é um bloco de texto ou uma imagem incorporada, diferenciados por um campo Kind:
type
TPDFlibPageElementKind = (ekText, ekImage);
TPDFlibPageElement = record
Kind: TPDFlibPageElementKind;
Page: Integer;
Left, Top, Right, Bottom: Double;
Text: WideString; // ekText
FontName: WideString; // ekText
FontSize: Double; // ekText
ImageID: Integer; // ekImage; usable with SelectImage / GetImageID
end;
Elementos de texto vêm da mesma passagem do ExtractPageTextBlocks, de modo que cada um chega com seu retângulo, seu nome de fonte e seu tamanho já preenchidos. Os elementos de imagem vêm da lista de imagens incorporadas da página via FindImages e GetImageID; o ImageID que eles carregam é o identificador que você fornece para SelectImage para inspecionar a imagem mais a fundo. Ambos os tipos chegam em um único array, de modo que uma única varredura sobre a página enxerga tudo o que há nela
var
Pdf: TPDFlib;
Elems: array[0..511] of TPDFlibPageElement;
Total, I: Integer;
begin
Pdf := TPDFlib.Create(nil);
try
Pdf.LoadFromFile('report.pdf', '');
Total := Pdf.EnumPageElements(1, Elems);
for I := 0 to Total - 1 do
if I <= High(Elems) then
if Elems[I].Kind = ekText then
WriteLn(Format('text %s/%.1f "%s"',
[Elems[I].FontName, Elems[I].FontSize, Elems[I].Text]))
else
WriteLn(Format('image id=%d', [Elems[I].ImageID]));
finally
Pdf.Free;
end;
end;
Existe uma convenção de contagem aqui que segue o resto da biblioteca e que você deve respeitar, caso contrário lerá memória não inicializada. O valor de retorno é a contagem total de elementos, que pode ser maior do que o array que você passou. A função preenche apenas a quantidade de espaços que couberem e continua contando o restante, exatamente da forma que funciona a enumeração de assinaturas. Portanto, a salvaguarda é sempre a mesma: limite o seu loop ao menor valor entre a contagem retornada e High(array), nunca itere cegamente até o valor total da contagem. Os exemplos acima mostram a verificação I <= High(...) por essa razão. Se o valor de retorno exceder o seu buffer, redimensione um array maior e chame a função novamente
Se você já usou as chamadas de bloco de texto de nível mais baixo da biblioteca, esta é a camada tipada e ciente da geometria sobre elas; a extração subjacente é a mesma descrita em extração de texto, imagem e fonte em PDF no Delphi com o PDFlibPas. E quando o objetivo não é "onde está este texto", mas "como este documento é estruturado para tecnologia assistiva", o equivalente paralelo no lado da leitura é a árvore de estrutura de PDF etiquetado (tagged PDF), que expõe a ordem lógica de leitura em vez do layout físico do bloco
Consultas de região quando você já sabe onde procurar
Às vezes você não tem um termo de pesquisa de forma alguma; você tem um retângulo. Um modelo de formulário sempre coloca o número da fatura no canto superior direito, ou um layout escaneado reserva uma faixa fixa para uma tabela. O método GetTextInAreaEx atende a esse caso. Ele é o equivalente com suporte a limites do GetTextInArea: enquanto a chamada mais antiga retorna uma lista plana de strings para uma região, a nova retorna o retângulo de cada bloco retido junto com o seu texto, de modo que você descobre não apenas o que está na caixa, mas onde cada linha se posiciona dentro dela
var
Pdf: TPDFlib;
Hits: array[0..63] of TPDFlibSearchHit;
Found, I: Integer;
begin
Pdf := TPDFlib.Create(nil);
try
Pdf.LoadFromFile('invoice.pdf', '');
Pdf.SelectPage(1);
// Left, Top, Width, Height in PDF points on the selected page.
Found := Pdf.GetTextInAreaEx(360, 720, 180, 60, Hits);
for I := 0 to Found - 1 do
if I <= High(Hits) then
WriteLn(Hits[I].MatchText);
finally
Pdf.Free;
end;
end;
Duas coisas a ter em mente. O GetTextInAreaEx trabalha na página selecionada no momento, portanto chame SelectPage primeiro; diferentemente do SearchText, ele não recebe um intervalo. E um bloco é mantido quando ele intercepta o retângulo da consulta, não apenas quando está totalmente contido, de modo que uma linha que cruza o limite ainda é retornada. Isso geralmente é o que você deseja para uma caixa de seleção desenhada à mão, mas se você precisar de contenção estrita, pode filtrar os retângulos retornados por conta própria, já que agora você os possui
Colocando em prática
O ponto comum entre todas as três chamadas é que geometria não é mais algo que você reconstrói após o fato. Uma correspondência de pesquisa conhece sua página e sua caixa delimitadora. Um elemento de página conhece seu retângulo e, no caso de texto, sua fonte. Uma consulta de região relata onde cada linha cai. Isso é o suficiente para construir um recurso real de localização e destaque (find-and-highlight), um índice de clique para localizar ou um extrator ciente do layout sem precisar descer abaixo da API pública ou reconstruir o pipeline de extração de texto manualmente
Essas APIs de consulta são fornecidas como parte da Biblioteca PDF PDFlibPas Delphi, junto com a camada de extração de blocos de texto completa sobre a qual são construídas e o restante da superfície de introspecção no lado da leitura para Delphi e C++Builder