Artigo Técnico

Seleção de Linha de Texto PDF com Char Boxes PDFium em Delphi

Uma página de texto PDF expõe carateres e caixas, nunca linhas. O PDFium Component constrói uma linha visual agrupando caixas de carateres cujos centros verticais caem dentro de metade da altura do carater semente, percorrendo para fora a partir do carater clicado até a tolerância ser excedida. Todos os percursos de seleção no visualizador chamam esse único auxiliar, pelo que rato, teclado e código concordam

O sintoma que traz alguém a esta procura é específico e desagradável. Um utilizador faz um triplo-clique num parágrafo num relatório de duas colunas e obtém metade da página. Ou faz triplo-clique numa célula de tabela e a seleção engole a linha inteira mais o número de página no rodapé. O visualizador não está partido; está a responder a uma pergunta que o ficheiro não consegue responder. Não há uma linha num PDF para selecionar, e qualquer implementação que finja o contrário está a adivinhar. Este artigo é sobre tornar a adivinhação deliberada e torná-la consistente. Se o que realmente precisa é extrair texto de um documento, veja extrair texto de documentos PDF com PDFium; se está a fazer layout de texto e precisa de larguras, veja medição de texto e quebra de linha. Aqui o assunto é mais estreito: decidir onde começa e termina uma linha visual, e selecionar exatamente isso

Por que não tem uma página de texto PDF objetos de linha?

Porque um stream de conteúdo PDF descreve desenho, não estrutura. A ISO 32000-1 §9.4 define um objeto de texto como um par BT / ET contendo operadores de posicionamento e apresentação. Os operadores de posicionamento da §9.4.2 (Td, TD, Tm, T*) movem uma matriz de texto pela página, e os operadores de apresentação da §9.4.3 (Tj, TJ, ', ") pintam glifos onde quer que essa matriz atualmente aponte. Nada nesse modelo diz "esta sequência de glifos é uma linha". Uma linha é o que um humano vê depois de a pintura estar feita

Os produtores tornam isto pior de formas que não consegue controlar. Um parágrafo justificado pode ser emitido como um array TJ por linha, ou como um Tj por palavra com um Tm explícito antes de cada uma, ou como uma única operação de apresentação com ajustes de kerning a transportar o espaçamento. Um layout de duas colunas pode emitir a coluna esquerda de cima a baixo e depois a direita, ou pode intercalá-las se o produtor percorreu a sua própria lista de objetos interna por outra ordem. A sequência de carateres que o PDFium lhe entrega segue o stream de conteúdo, e o stream de conteúdo segue o que quer que a aplicação geradora tenha achado por bem fazer. Assim, as duas funções que realmente obtém são FPDFText_CountChars, que reporta quantos carateres a página contém, e FPDFText_GetCharBox, que devolve a caixa delimitadora de um carater em espaço de página. Esse é todo o vocabulário em bruto. Tudo acima disso, palavras, linhas, parágrafos, colunas, é inferência que executa sobre geometria

Por que é errado testar CR e LF?

Porque os carateres contra os quais testaria não estão de forma fiável presentes, e quando estão presentes não são de forma fiável seus. O PDFium injeta carateres sintéticos na página de texto para tornar o texto extraído legível: um espaço onde duas sequências estão visualmente separadas, um CR ou LF onde a sequência seguinte começa numa nova linha de base. FPDFText_IsGenerated existe precisamente para os poder distinguir de carateres que vieram do ficheiro, e o PDFium Component expõe-no como a propriedade CharacterGenerated

Divida nesses carateres e herda cada julgamento que o PDFium fez ao sintetizá-los. Uma quebra de linha rígida dentro de um parágrafo quebrado e uma quebra suave têm aspeto idêntico depois da síntese. Uma linha de tabela que o produtor emitiu célula a célula pode não obter qualquer quebra entre a última célula e a primeira célula da linha seguinte, porque as linhas de base calham de estar suficientemente próximas. Entretanto, um cabeçalho seguido de texto corrente num tamanho diferente pode obter duas quebras onde um humano vê uma. Os carateres gerados são uma conveniência de renderização para extração de página inteira; não são um modelo de linha, e degradam-se exatamente nos documentos onde a seleção mais importa

Agrupar caixas de carateres pelo centro vertical

O sinal fiável é a geometria. Tome o carater que o utilizador clicou como semente, calcule o centro vertical da sua caixa, e percorra para fora em ambas as direções enquanto caixas vizinhas mantêm os seus centros verticais dentro da tolerância. O PDFium Component usa metade da altura da caixa semente como essa tolerância, com um piso de 0.5 unidades de página para que caixas degeneradas, um ponto final, um espaço fino, um glifo com uma caixa de altura quase zero, não colapsem a tolerância a nada e cortem a linha após um único carater

function TPdfView.LineRangeAt(TxtPage: FPDF_TEXTPAGE; CharIndex: Integer;
  out StartIndex, Count: Integer): Boolean;
var
  Lo, Hi, Total: Integer;
  SeedBox, Box: TPdfRectangle;
  SeedYMid, BoxYMid, HalfH: Double;
begin
  Result := False;
  StartIndex := -1;
  Count := 0;
  Total := FPDFText_CountChars(TxtPage);
  if (CharIndex < 0) or (CharIndex >= Total) then
    Exit;

  if FPDFText_GetCharBox(TxtPage, CharIndex, SeedBox.Left, SeedBox.Right,
    SeedBox.Bottom, SeedBox.Top) = 0 then
    Exit;
  SeedYMid := (SeedBox.Top + SeedBox.Bottom) / 2;
  HalfH := Abs(SeedBox.Top - SeedBox.Bottom) / 2;
  if HalfH < 0.5 then          // floor for degenerate boxes
    HalfH := 0.5;

  Lo := CharIndex;
  Hi := CharIndex;
  while Lo > 0 do
  begin
    if FPDFText_GetCharBox(TxtPage, Lo - 1, Box.Left, Box.Right,
      Box.Bottom, Box.Top) = 0 then
      Break;
    BoxYMid := (Box.Top + Box.Bottom) / 2;
    if Abs(BoxYMid - SeedYMid) > HalfH then
      Break;
    Dec(Lo);
  end;
  while Hi < Total - 1 do
  begin
    if FPDFText_GetCharBox(TxtPage, Hi + 1, Box.Left, Box.Right,
      Box.Bottom, Box.Top) = 0 then
      Break;
    BoxYMid := (Box.Top + Box.Bottom) / 2;
    if Abs(BoxYMid - SeedYMid) > HalfH then
      Break;
    Inc(Hi);
  end;
  StartIndex := Lo;
  Count := Hi - Lo + 1;
  Result := True;
end;

Três detalhes nesse ciclo ganham o seu lugar. A tolerância deriva da semente em vez de uma constante, pelo que um cabeçalho de 24pt obtém uma banda larga e um texto de nota de rodapé de 7pt obtém uma estreita, e nenhum rouba carateres ao seu vizinho. A comparação usa centros verticais em vez de linhas de base ou topos de caixa, o que mantém um sobrescrito, uma sequência de tamanho diferente incorporada, ou uma frase de tipo de letra misto na mesma linha que os seus vizinhos. E um FPDFText_GetCharBox falhado termina a varredura em vez de ser saltado, porque um carater sem geometria recuperável não lhe dá qualquer evidência em qualquer direção, e continuar além dele deixaria a varredura saltar através de uma fronteira genuína com base na força de um carater mais adiante

Por que tem cada percurso de seleção de partilhar um auxiliar?

Porque três percursos de código que implementam cada um "a linha" vão divergir, e vão divergir silenciosamente. No PDFium Component, a expansão por triplo-clique, Shift+Home, Shift+End, e o método público SelectLineAt resolvem todos os seus limites através da mesma chamada LineRangeAt. O triplo-clique semeia-a a partir da âncora de seleção; as teclas shift semeiam-na a partir do cursor de seleção e movem apenas essa extremidade; SelectLineAt semeia-a a partir de um índice de carater fornecido por quem chama e entrega o resultado a SelectTextRange, o mesmo validador de intervalo que o percurso do rato usa. Duplique a lógica em vez disso e a falha não é um crash, é uma deriva lenta. Alguém afina a tolerância do triplo-clique para corrigir um relatório com entrelinha apertada, e agora Shift+End para um carater antes de onde o triplo-clique para no mesmo parágrafo. Um utilizador seleciona uma linha com o rato, estende-a com o teclado, e vê a seleção encolher. Porque SelectLineAt alimenta o pipeline de seleção comum, a seleção programática também permanece independente de a entrada do rato estar ativada, e ainda obtém validação de intervalo, repintura, e a notificação OnSelectionChange de graça

// Select the visual line under a client-space point, then read it back
procedure TForm1.SelectLineUnderCursor(X, Y: Integer);
var
  CharIndex: Integer;
begin
  CharIndex := PdfView1.CharacterIndexAtPos(X, Y, 6.0, 6.0);
  if CharIndex < 0 then
    Exit;
  if PdfView1.SelectLineAt(PdfView1.CurrentPage, CharIndex) then
    Memo1.Lines.Add(PdfView1.SelectedText);
end;

Note os argumentos de tolerância em CharacterIndexAtPos. O teste de acerto tem a sua própria folga, expressa em unidades de página, e é uma preocupação separada da tolerância de linha. Um clique que aterra na entrelinha entre duas linhas resolve-se para o carater mais próximo dentro dessa caixa; a varredura de linha corre então a partir do carater que isso se revelou ser. Alimentar uma tolerância de acerto demasiado generosa na semente é uma das formas mais fáceis de selecionar uma linha para a qual o utilizador não estava a apontar

Dois espaços de índice: índice de carater e índice de texto

Uma vez com um intervalo em mãos, resista à tentação de o usar como um deslocamento de string. FPDFText_GetText devolve o texto da página como um buffer UTF-16, mas os seus índices não são o mesmo espaço de índice que os índices de carater usados por FPDFText_GetCharBox e FPDFText_CountChars. Os carateres gerados discutidos antes sentam-se no buffer de texto enquanto ocupam ranhuras de carater sem geometria utilizável, e as duas numerações afastam-se ao longo da página. As pontes são FPDFText_GetTextIndexFromCharIndex e FPDFText_GetCharIndexFromTextIndex, envolvidas pelo PDFium Component como CharacterIndexToTextIndex e TextIndexToCharacterIndex

var
  TextStart, TextEnd: Integer;
begin
  // char-index range from LineRangeAt -> offsets into the page text buffer
  TextStart := Pdf.CharacterIndexToTextIndex(StartIndex);
  TextEnd   := Pdf.CharacterIndexToTextIndex(StartIndex + Count - 1);
  if (TextStart >= 0) and (TextEnd >= TextStart) then
    Caption := Pdf.Text(TextStart, TextEnd - TextStart + 1);
end;

A direção que morde mais forte é a inversa. Uma pesquisa implementada sobre a string extraída dá-lhe índices de texto, e passá-los diretamente a uma API de caixa ou seleção endereça silenciosamente os carateres errados, com um erro que cresce quanto mais desce na página. Converta com TextIndexToCharacterIndex antes de qualquer coisa geométrica tocar no número. Os pares surrogate acrescentam um segundo problema de deslocamento, independente, em cima disto, coberto no artigo sobre emoji, CJK e pares surrogate

Onde a heurística vergue

Seja honesto consigo mesmo quanto aos limites, porque são reais e são alcançáveis. O texto rodado é o caso mais claro: uma caixa de carater é um retângulo alinhado aos eixos em espaço de página, pelo que para texto rodado 90 graus, as caixas de uma linha visual têm centros verticais espalhados pela página, e a varredura para quase de imediato. O que obtém é uma seleção curta em vez de uma errada, que é o melhor modo de falha, mas continua a ser uma falha. Os modos de escrita vertical comportam-se da mesma forma pela mesma razão. Os layouts de duas colunas funcionam quando as colunas estão verticalmente desfasadas uma da outra e quebram quando não estão. Se ambas as colunas partilham uma grelha de linha de base, os carateres da coluna direita ficam dentro da tolerância da linha da coluna esquerda, e a varredura vai correr diretamente através do intervalo, porque em geometria pura não há nada ali para parar. Detetar isso precisa de um teste de espaço horizontal em cima do agrupamento vertical, e escolher o limiar do espaço é um julgamento próprio sobre em que documentos está disposto a errar. Os tamanhos de tipo de letra mistos são o caso que a tolerância relativa à semente trata bem: um trecho de código incorporado de 8pt dentro de texto corrente de 11pt mantém o seu centro dentro da banda, e um cabeçalho de 24pt na linha de base seguinte não puxa a linha corrente para si

A semântica de seleção de linha aqui descrita é disponibilizada no PDFium Component para Delphi e C++Builder, ao lado das APIs de teste de acerto, intervalo de seleção e índice de texto usadas nos exemplos; a página do produto contém a referência completa para a página de texto e o modelo de seleção