Uma página de texto PDF expõe caracteres e caixas, nunca linhas. O PDFium Component constrói uma linha visual agrupando caixas de caractere cujos centros verticais caem dentro de metade da altura do caractere semente, varrendo para fora a partir do caractere clicado até a tolerância ser excedida. Todo caminho de seleção no visualizador chama esse único helper, então mouse, teclado, e código concordam
O sintoma que traz você até aqui é específico e desagradável. Um usuário faz clique triplo em um parágrafo em um relatório de duas colunas e obtém metade da página. Ou faz clique triplo em uma célula de tabela e a seleção engole a linha inteira mais o número da página no rodapé. O visualizador não está quebrado; ele está fazendo uma pergunta que o arquivo não pode responder. Não há linha em um PDF para selecionar, e qualquer implementação que finge o contrário está adivinhando. Este artigo trata de tornar o palpite deliberado e torná-lo consistente. Se o que você realmente precisa é extrair texto de um documento, veja extraindo texto de documentos PDF com PDFium; se você está diagramando texto e precisa de larguras, veja medição de texto e quebra de palavra. Aqui o assunto é mais estreito: decidir onde uma linha visual começa e termina, e selecionar exatamente isso
Por que uma página de texto PDF não tem objetos de linha?
Porque um content stream de 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 exibiçã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 exibiçã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 que a pintura está feita
Produtores pioram isso de formas que você não pode 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 exibição com ajustes de kerning carregando o espaçamento. Um layout de duas colunas pode emitir a coluna esquerda de cima para baixo e depois a direita, ou pode intercalá-las se o produtor percorreu sua própria lista de objetos interna em uma ordem diferente. A sequência de caracteres que o PDFium entrega a você segue o content stream, e o content stream segue o que quer que a aplicação geradora tenha decidido fazer. Então as duas funções que você realmente tem são FPDFText_CountChars, que reporta quantos caracteres a página contém, e FPDFText_GetCharBox, que retorna a caixa delimitadora de um caractere no espaço da página. Esse é todo o vocabulário bruto. Tudo acima disso, palavras, linhas, parágrafos, colunas, é inferência que você realiza sobre geometria
Por que detecção de CR e LF é o teste errado?
Porque os caracteres contra os quais você testaria não estão presentes de forma confiável, e quando estão presentes não são confiavelmente seus. O PDFium injeta caracteres sintéticos na página de texto para tornar o texto extraído legível: um espaço onde duas sequências são visualmente separadas, um CR ou LF onde a próxima sequência começa em uma nova linha de base. FPDFText_IsGenerated existe precisamente para que você possa distingui-los de caracteres que vieram do arquivo, e o PDFium Component o expõe como a propriedade CharacterGenerated
Divida nesses caracteres e você herda todo julgamento que o PDFium fez ao sintetizá-los. Uma quebra de linha rígida dentro de um parágrafo quebrado e uma quebra suave parecem idênticas depois da síntese. Uma linha de tabela que o produtor emitiu célula por célula pode não receber nenhuma quebra entre a última célula e a primeira célula da próxima linha, porque as linhas de base acontecem de estar próximas o suficiente. Enquanto isso um cabeçalho seguido de texto do corpo em um tamanho diferente pode receber duas quebras onde um humano vê uma. Os caracteres 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 exatamente nos documentos onde seleção mais importa
Agrupando caixas de caractere pelo centro vertical
O sinal confiável é geometria. Pegue o caractere que o usuário clicou como semente, calcule o centro vertical de sua caixa, e caminhe para fora em ambas as direções enquanto caixas vizinhas mantêm 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 depois de um caractere
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 laço ganham seu lugar. A tolerância deriva da semente em vez de uma constante, então um título de 24pt recebe uma banda larga e texto de rodapé de 7pt recebe uma estreita, e nenhum rouba caracteres do outro. 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 inline, ou uma frase de fonte mista na mesma linha que seus vizinhos. E um FPDFText_GetCharBox falho termina a varredura em vez de ser pulado, porque um caractere sem geometria recuperável não dá evidência nenhuma de qualquer forma, e continuar além dele deixaria a caminhada pular através de um limite genuíno com base na força de um caractere mais adiante
Por que todo caminho de seleção precisa compartilhar um helper?
Porque três caminhos de código que cada um implementa "a linha" vão divergir, e vão divergir silenciosamente. No PDFium Component, expansão de clique triplo, Shift+Home, Shift+End, e o método público SelectLineAt todos resolvem seus limites através da mesma chamada LineRangeAt. Clique triplo a semeia a partir da âncora de seleção; as teclas shift a semeiam a partir do cursor de seleção e movem apenas essa ponta; SelectLineAt a semeia a partir de um índice de caractere fornecido pelo chamador e entrega o resultado para SelectTextRange, o mesmo validador de intervalo que o caminho do mouse usa. Duplique a lógica em vez disso e a falha não é uma queda, é uma deriva lenta. Alguém ajusta a tolerância de clique triplo para corrigir um relatório com entrelinhas apertadas, e agora Shift+End para um caractere antes de onde clique triplo para no mesmo parágrafo. Um usuário seleciona uma linha com o mouse, a estende com o teclado, e observa a seleção encolher. Porque SelectLineAt alimenta o pipeline de seleção comum, seleção programática também permanece independente de se a entrada do mouse está habilitada, e ainda recebe 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;
Observe os argumentos de tolerância em CharacterIndexAtPos. Teste de acerto tem sua própria folga, expressa em unidades de página, e é uma preocupação separada da tolerância de linha. Um clique que cai na entrelinha entre duas linhas resolve para o caractere mais próximo dentro dessa caixa; a varredura de linha então roda a partir de qualquer caractere que isso acabou sendo. Alimentar uma tolerância de acerto generosa demais na semente é uma das formas mais fáceis de selecionar uma linha que o usuário não estava apontando
Dois espaços de índice: índice de caractere e índice de texto
Uma vez que você tenha um intervalo, resista à tentação de usá-lo como um offset de string. FPDFText_GetText retorna o texto da página como um buffer UTF-16, mas seus índices não são o mesmo espaço de índice que os índices de caractere usados por FPDFText_GetCharBox e FPDFText_CountChars. Os caracteres gerados discutidos antes ficam no buffer de texto enquanto ocupam slots de caractere sem geometria utilizável, e as duas numerações se afastam ao longo da página. As pontes são FPDFText_GetTextIndexFromCharIndex e FPDFText_GetCharIndexFromTextIndex, envolvidos 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 reversa. Uma busca implementada sobre a string extraída dá a você índices de texto, e passá-los diretamente para uma API de caixa ou seleção endereça silenciosamente os caracteres errados, com um erro que cresce quanto mais fundo você vai na página. Converta com TextIndexToCharacterIndex antes de qualquer coisa geométrica tocar o número. Pares substitutos adicionam um segundo problema de offset, independente, em cima disso, que é coberto no artigo sobre emoji, CJK, e pares substitutos
Onde a heurística se dobra
Seja honesto consigo mesmo sobre os limites, porque são reais e são alcançáveis. Texto rotacionado é o caso mais claro: uma caixa de caractere é um retângulo alinhado aos eixos no espaço da página, então para texto rotacionado 90 graus as caixas de uma linha visual têm centros verticais espalhados pela página, e a varredura para quase imediatamente. O que você obtém é uma seleção curta em vez de uma errada, que é o modo de falha melhor, mas ainda é uma falha. Modos de escrita vertical se comportam da mesma forma pelo mesmo motivo. Layouts de duas colunas funcionam quando as colunas estão deslocadas verticalmente uma da outra e quebram quando não estão. Se ambas as colunas compartilham uma grade de linha de base, caracteres da coluna direita ficam dentro da tolerância da linha da coluna esquerda, e a varredura vai correr direto através do canal, porque em geometria pura não há nada ali para parar. Detectar isso precisa de um teste de lacuna horizontal em cima do agrupamento vertical, e escolher o limiar de lacuna é seu próprio julgamento sobre quais documentos você está disposto a errar. Tamanhos de fonte mistos são o caso que a tolerância relativa à semente trata bem: um trecho de código de 8pt inline dentro de texto de corpo de 11pt mantém seu centro dentro da banda, e um título de 24pt na próxima linha de base não puxa a linha do corpo para si
A semântica de seleção de linha descrita aqui é fornecida no PDFium Component para Delphi e C++Builder, junto com as APIs de teste de acerto, intervalo de seleção, e índice de texto usadas nos exemplos; a página do produto traz a referência completa para o modelo de página de texto e seleção