Artigo Técnico

Extração de texto PDF no Delphi: espaços e quebras de linha

O HotPDF Delphi Component reconstrói espaços entre palavras e quebras de linha no THotPDF.ExtractLoadedPageText a partir da geometria dos glifos, e não de caracteres de espaço. Um espaço entra quando o vão depois da largura própria de um glifo excede 0.15 da altura do texto, e uma linha nova começa só quando a origem do texto se move através da direção de escrita por mais de metade da altura do texto. Desde a v2.768.3 que o texto da página também inclui texto pintado através de Form XObjects e deixa de fora glifos fora da área de recorte visível. O resto deste artigo explica porque cada regra tem o aspeto que tem, porque todas elas substituíram uma regra mais simples que produzia output plausível mas errado em documentos reais

Os sintomas são familiares a quem já meteu texto PDF num índice de pesquisa. Uma capa extrai como PDFReferenceManualNovember4,1998, um formulário fiscal parte-se em 156 linhas, uma marca de água diagonal chega com um carácter por linha, e uma prova recortada abre com a linha de slug da impressora que visualizador nenhum mostra. Nenhum destes ficheiros está partido. Cada um usa uma forma perfeitamente legal de posicionar texto que um extrator ingénuo lê mal

Porque é que o texto PDF extraído perde os espaços entre palavras?

O texto extraído perde os espaços entre palavras porque um PDF nunca é obrigado a contê-los. Um produtor pode separar palavras mostrando um carácter de espaço, mas pode igualmente mover a caneta com um número dentro de uma matriz TJ (ISO 32000-1 §9.4.3) ou com um Td fresco (§9.4.2), e o output de TeX, muitos ficheiros Distiller e a maioria dos layouts justificados fazem exatamente isso. Antes da v2.766.76, o HPDFAssemblePageText só olhava ao movimento vertical, por isso uma quebra de palavra feita por posicionamento simplesmente desaparecia. O assembler agora mede, ao longo da direção de escrita do glifo anterior, a distância do fim da largura própria desse glifo à origem do glifo corrente, e insere um espaço quando a distância excede 0.15 da altura da caixa do glifo corrente, medida de ascendente a descendente no user space. Não se acrescenta espaço quando qualquer dos lados já é branco, nem entre dois caracteres CJK, porque a justificação estica os ideogramas sem esse esticar significar uma fronteira de palavra. Os registos de glifo expõem a mesma geometria, por isso consegue reproduzir a decisão quando um ficheiro concreto o deixa perplexo

uses
  SysUtils, HPDFDoc, HPDFContentStream;

procedure DumpWordGaps(Pdf: THotPDF; PageIndex: Integer);
var
  Glyphs: THPDFGlyphArray;
  I: Integer;
  Height, Gap: Double;
begin
  if not Pdf.ExtractLoadedPageGlyphs(PageIndex, Glyphs) then
    Exit;
  for I := 1 to High(Glyphs) do
  begin
    // altura de ascendente a descendente da caixa do glifo, no user space
    Height := Sqrt(Sqr(Glyphs[I].QuadX[3] - Glyphs[I].QuadX[0]) +
      Sqr(Glyphs[I].QuadY[3] - Glyphs[I].QuadY[0]));
    // texto horizontal: vão desde o fim da largura própria do glifo anterior
    Gap := Glyphs[I].BaselineStartX - Glyphs[I - 1].GlyphEndX;
    if (Height > 0) and (Gap > 0.15 * Height) then
      Writeln(Format('U+%.4x gap %.2f height %.2f: space',
        [Glyphs[I].Unicode, Gap, Height]));
  end;
end;

Porque medir pela largura própria do glifo em vez da posição da caneta?

O HotPDF mede os vãos entre palavras a partir de GlyphEndX / GlyphEndY porque a posição da caneta depois de um glifo já contém espaçamento que não é um vão. A ISO 32000-1 §9.4.4 define o deslocamento horizontal como a largura do glifo vezes o tamanho da fonte, mais o espaçamento de caracteres Tc, mais o espaçamento de palavras Tw, tudo escalado por Tz. O BaselineEndX / BaselineEndY guarda esse deslocamento completo, enquanto o GlyphEndX / GlyphEndY guarda só o avanço da fonte e o Tz. A diferença interessa para produtores que apertam o tracking com um Tc negativo e depois devolvem o espaço através de um ajuste TJ depois de cada glifo: medido pela posição da caneta, a devolução parece um vão, e o termo chinês “95后” extraía-se como “9 5 后”. O limiar está ligado à altura da caixa do glifo em vez do tamanho Tf por razão semelhante. Exportações do Word escrevem frequentemente 1 Tf e carregam o tamanho real num Tm escalado, por isso o Tfs diz 1 enquanto o texto tem 10 pontos de altura, e uma regra chaveada no Tfs trataria as duas grafias da mesma página de forma diferente

A regra de espaço entre palavras do HotPDF para o ExtractLoadedPageText no Delphi: um espaço só é inserido quando a distância do GlyphEndX do glifo anterior ao BaselineStartX do glifo seguinte excede 0.15 da altura da caixa de ascendente a descendente, porque a posição da caneta no BaselineEndX já contém Tc, Tw e Tz e transforma devoluções de tracking justificado em falsos vãos como 9 5 后
A geometria, e não caracteres de espaço, decide onde as palavras partem — os registos de glifo expõem as mesmas medições, por isso consegue reproduzir a decisão para qualquer ficheiro desconcertante

A regra tem arestas honestas. Um título composto com tracking muito frouxo, onde o Tc sozinho abre mais de 0.15 da altura do texto entre letras, extrai com um espaço entre cada letra, que é o que a página parece mas provavelmente não o que queria indexar. Pedaços desenhados fora de ordem na mesma linha de base produzem um vão negativo e juntam-se sem espaço. Nenhum dos casos é comum em texto corrido, e num corpus de testes a alteração subiu a correspondência de palavras contra um extrator de referência em 28 páginas sem baixar nenhuma

Quando é que o HotPDF começa uma linha nova no texto extraído?

Desde a v2.766.79, uma linha nova começa quando o movimento da origem do glifo anterior para a corrente, projetado na normal da direção de escrita anterior, excede metade da maior altura de caixa dos dois glifos. A regra anterior comparava o movimento Y cru com metade do Tfs, o que falhava em duas direções. Com 1 Tf e um Tm escalado o limiar encolhia para meia unidade, por isso um expoente elevado por uma subida de texto de 0.4 ou a trepidação banal da linha de base partia a linha. A regra também ignorava o X por completo, por isso texto sob um Tm rodado descia a página a cada glifo e saía com um glifo por linha. Projetar na normal da direção faz corridas rodadas comportarem-se como horizontais, e pegar na maior das duas alturas mantém uma palavra de amostra grande e a sua legenda pequena na mesma linha quando partilham a linha de base. No formulário fiscal referido acima, a contagem de linhas caiu de 156 para 97. Texto vertical em modo de escrita 1 (§9.7.4.3) segue um caminho separado: esses glifos são agrupados em colunas, lidos da direita para a esquerda e de cima para baixo, com uma quebra de linha a cada mudança de coluna

Como o ExtractLoadedPageText do HotPDF decide quebras de linha no Delphi: o movimento entre origens de glifos é projetado na normal da direção de escrita e comparado com metade da maior altura de caixa, por isso um expoente elevado por uma pequena subida de texto sob uma fonte 1 Tf e texto que desce a página sob um Tm rodado já não se partem num glifo por linha
A projeção faz corridas rodadas comportarem-se como horizontais, e pegar na maior das duas alturas de caixa mantém uma palavra de amostra grande e a sua legenda pequena na mesma linha

Que texto o ExtractLoadedPageText inclui ou deixa de fora?

O ExtractLoadedPageText devolve o texto que um visualizador mostra. Desde a v2.766.80 que trabalha só com os glifos visíveis, deixando cair todo o glifo cujo centro de caixa caia fora do GetLoadedPageVisibleBox, que é a CropBox recortada ao MediaBox (§14.11.2). Isso remove linhas de slug e outras marcas de impressora postas como texto fora da área de corte. O ExtractLoadedPageGlyphs mantém deliberadamente a devolver todos os glifos do content stream da página, por isso ainda consegue encontrar esse material quando precisar. O filtro é um teste de caixa, não um teste de visibilidade: texto escondido por um clipping path, pintado a branco ou coberto por uma imagem continua a ser extraído

var
  Pdf: THotPDF;
  Glyphs: THPDFGlyphArray;
  PageText: UnicodeString;
  L, B, R, T: Single;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.LoadFromFile('trimmed-proof.pdf');
    if Pdf.GetLoadedPageVisibleBox(0, L, B, R, T) then
      Writeln(Format('Visible box: %.1f %.1f %.1f %.1f', [L, B, R, T]));
    // todos os glifos do content stream da página, linha de slug incluída
    if Pdf.ExtractLoadedPageGlyphs(0, Glyphs) then
      Writeln(Length(Glyphs), ' glyphs in the page content stream');
    // só o que a página mostra, com texto de Form XObject interpolado
    if Pdf.ExtractLoadedPageText(0, PageText) then
      Writeln(PageText);
  finally
    Pdf.Free;
  end;
end;

Texto pintado através de Form XObjects faz parte do texto da página desde a v2.768.3. Cabeçalhos, carimbos e marcas de água vivem muitas vezes em formulários, e alguns documentos de normas perdiam 30 a 35 por cento dos seus caracteres antes da alteração. O THotPDF.InterpretContentWithForms regista cada Do junto com a CTM em vigor, interpreta o formulário pela sua /Matrix vezes essa CTM (§8.10.1), e interpola os glifos do formulário na posição do Do, recorrendo a formulários aninhados. Um formulário sem /Resources próprio toma emprestados os do stream que o pinta, como a §7.8.3 permite. Os glifos de formulário carregam TokenIndex = -1, e o ExtractLoadedPageGlyphs continua a devolver só glifos do stream da página, porque pesquisar, substituir e redigir escrevem alterações de volta através do TokenIndex e editariam os bytes errados se um glifo de formulário se esgueirasse. Duas simplificações valem a pena conhecer: o texto de formulário não é recortado ao /BBox do formulário, e a recursão para aos 12 níveis em vez de por deteção de ciclos, por isso um formulário mal formado que se pinta a si mesmo repete o seu texto até chegar a esse teto

Que glifos o HotPDF inclui ao extrair texto de páginas PDF no Delphi: o ExtractLoadedPageText guarda só glifos cujo centro de caixa caia dentro do GetLoadedPageVisibleBox, a CropBox recortada ao MediaBox, por isso as linhas de slug da impressora desaparecem, enquanto o InterpretContentWithForms interpola glifos de Form XObject em cada posição de Do com TokenIndex posto a -1 e a API ao nível do glifo continua a devolver tudo
Um teste de caixa no centro do glifo não é um teste de visibilidade — texto branco, texto recortado e texto coberto continuam a sair, e o texto de formulário conta desde a v2.768.3

Porque é que o texto depois de um operador Q descodificava como lixo?

Texto depois de Q podia descodificar mal antes da v2.766.73 porque o extrator só guardava a CTM no q. Os parâmetros de estado de texto, nomeadamente fonte, tamanho, Tc, Tw, Tz, TL, modo de renderização e subida, pertencem ao estado gráfico (§9.3.1), por isso o Q tem de os repor junto com tudo o resto na pilha (§8.4.2). Um relatório da indústria selecionava uma fonte Identity-H de dois bytes dentro de q … Q e depois mostrava texto WinAnsi de um byte sem Tf próprio. O extrator mantinha a fonte interior, lia os pontinhos e a palavra “Adobe” no índice como códigos de dois bytes, e largava 15% dos caracteres da página. A pilha de q/Q do intérprete agora guarda o estado de texto completo. As regras de extração aqui descritas aplicam-se a todas as páginas, por isso um documento inteiro pode ir para um ficheiro numa só chamada

var
  Output: TFileStream;
  Pages: Integer;
begin
  Output := TFileStream.Create('report.txt', fmCreate);
  try
    // intervalo vazio = todas as páginas; form feed entre páginas; BOM UTF-8
    Pages := Pdf.ExtractLoadedPagesTextToStream(Output, '', #12, True);
    Writeln(Pages, ' pages extracted');
  finally
    Output.Free;
  end;
end;

Que API de texto do HotPDF deve usar?

O ExtractLoadedPageText fica pela ordem do content stream, que é a predefinição certa para pesquisa e indexação; a cadeia de descodificação por baixo dele está coberta em extrair texto de PDFs carregados com o HotPDF. Para documentos etiquetados cuja ordem de autoria interessa, a extração de texto pela ordem da estrutura percorre a árvore de estrutura em vez de adivinhar pela geometria, e para dados presos em tabelas, a extração de tabelas tipadas através de quebras de página devolve células em vez de linhas. A referência completa da API e uma transferência de avaliação estão na página de produto do HotPDF Delphi PDF Component