Artigo Técnico

Extrair texto de PDF no Delphi: espaços e quebras de linha

O HotPDF Delphi Component recria espaços de palavra e quebras de linha no THotPDF.ExtractLoadedPageText a partir da geometria dos glyphs, não de caracteres de espaço. Um espaço entra quando o vão depois da própria largura de um glyph excede 0.15 da altura do texto, e uma linha nova começa só quando a origem do texto se move na direção de escrita por mais da metade da altura do texto. Desde a v2.768.3 o texto da página também inclui texto pintado por Form XObjects e deixa de fora glyphs fora da área de crop visível. O resto deste artigo explica por que cada regra tem a forma que tem, porque cada uma delas substituiu uma regra mais simples que produzia saída plausível mas errada em documentos reais

Os sintomas são familiares para qualquer um que já alimentou texto de PDF a um índice de busca. Uma capa extrai como PDFReferenceManualNovember4,1998, um formulário de imposto se parte em 156 linhas, uma marca d'água diagonal chega um caractere por linha, e uma prova aparada abre com a slug line da impressora que nenhum viewer mostra. Nenhum desses arquivos está quebrado. Cada um usa um jeito perfeitamente legal de posicionar texto que um extrator ingênuo lê errado

Por que o texto extraído de PDF perde os espaços de palavra?

O texto extraído perde espaços de palavra porque um PDF nunca é obrigado a contê-los. Um produtor pode separar palavras mostrando um caractere de espaço, mas pode tão bem mover a caneta com um número dentro de um array TJ (ISO 32000-1 §9.4.3) ou com um Td fresco (§9.4.2), e saída de TeX, muitos arquivos de Distiller e a maioria dos layouts justificados fazem exatamente isso. Antes da v2.766.76, o HPDFAssemblePageText só olhava movimento vertical, então uma quebra de palavra feita por posicionamento simplesmente desaparecia. O assembler agora mede, ao longo da direção de escrita do glyph anterior, a distância do fim da própria largura desse glyph até a origem do glyph atual, e insere um espaço quando a distância excede 0.15 da altura do box do glyph atual, medida de ascent a descent em user space. Nenhum espaço é acrescentado quando qualquer um dos lados já é branco, e nenhum entre dois caracteres CJK, porque a justificação estica ideógrafos para longe sem que esse esticamento signifique fronteira de palavra. Os registros de glyph expõem a mesma geometria, então você pode reproduzir a decisão quando um arquivo em particular te deixa confuso

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 ascent-a-descent do box do glyph, em user space
    Height := Sqrt(Sqr(Glyphs[I].QuadX[3] - Glyphs[I].QuadX[0]) +
      Sqr(Glyphs[I].QuadY[3] - Glyphs[I].QuadY[0]));
    // texto horizontal: gap do fim da própria largura do glyph 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;

Por que medir a partir da própria largura do glyph em vez da posição da caneta?

O HotPDF mede vão de palavra a partir de GlyphEndX / GlyphEndY porque a posição da caneta depois de um glyph já contém espaçamento que não é vão. A ISO 32000-1 §9.4.4 define o deslocamento horizontal como a largura do glyph vezes o tamanho da fonte, mais o character spacing Tc, mais o word spacing Tw, tudo escalado pelo Tz. BaselineEndX / BaselineEndY guardam esse deslocamento completo, enquanto GlyphEndX / GlyphEndY guardam só o advance da fonte e o Tz. A diferença importa para produtores que apertam o tracking com um Tc negativo e então devolvem o espaço por um ajuste de TJ depois de cada glyph: medido da posição da caneta, a devolução parece um vão, e o termo chinês “95后” extraía como “9 5 后”. O threshold está amarrado à altura do box do glyph em vez do tamanho do Tf por razão parecida. Exports de Word frequentemente gravam 1 Tf e carregam o tamanho real num Tm escalado, então 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 de palavra do HotPDF para ExtractLoadedPageText em Delphi: um espaço é inserido só quando a distância do GlyphEndX do glyph anterior ao BaselineStartX do próximo excede 0.15 da altura do box de ascent a descent, 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ão como 9 5 后
A geometria, não os caracteres de espaço, decide onde palavras quebram — os registros de glyph expõem as mesmas medidas, então você pode reproduzir a decisão para qualquer arquivo intrigante

A regra tem arestas honestas. Um título composto com tracking muito frouxo, em que 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 você queria indexar. Pedaços desenhados fora de ordem numa mesma baseline produzem um vão negativo e se juntam sem espaço. Nenhum dos casos é comum em corpo de texto, e num corpus de teste a mudança elevou matches de palavra contra um extrator de referência em 28 páginas sem baixar nenhum

Quando 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 glyph anterior até o atual, projetado na normal da direção de escrita anterior, excede a metade da maior altura de box dos dois glyphs. 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 threshold encolhia para meia unidade, então um sobrescrito elevado por um text rise de 0.4 ou jitter de baseline comum quebrava a linha. A regra também ignorava o X por completo, então texto sob um Tm rotacionado descia a página a cada glyph e saía um glyph por linha. Projetar na normal da direção faz runs rotacionados se comportar como horizontais, e tomar a maior das duas alturas mantém uma palavra grande de amostra e a legenda pequena dela numa mesma linha quando compartilham baseline. No formulário de imposto mencionado acima, a contagem de linhas caiu de 156 para 97. Texto vertical em writing mode 1 (§9.7.4.3) segue um caminho separado: esses glyphs são agrupados em colunas, lidos da direita para a esquerda e de cima para baixo, com quebra de linha a cada troca de coluna

Como o ExtractLoadedPageText do HotPDF decide quebras de linha em Delphi: o movimento entre origens de glyphs é projetado na normal da direção de escrita e comparado com metade da maior altura de box, então um sobrescrito elevado por um text rise pequeno sob uma fonte 1 Tf e texto descendo a página sob um Tm rotacionado não se partem mais em um glyph por linha
A projeção faz runs rotacionados se comportar como horizontais, e tomar a maior das duas alturas de box mantém uma palavra grande de amostra e a legenda pequena dela numa mesma linha

Que texto o ExtractLoadedPageText inclui ou deixa de fora?

O ExtractLoadedPageText retorna o texto que um viewer mostra. Desde a v2.766.80 ele trabalha só dos glyphs visíveis, descartando todo glyph cujo centro de box cai fora do GetLoadedPageVisibleBox, que é a CropBox cortada pela MediaBox (§14.11.2). Isso remove slug lines e outras marcas de impressora compostas como texto fora da área de trim. O ExtractLoadedPageGlyphs deliberadamente continua retornando todo glyph do content stream da página, então você ainda encontra esse material quando precisar. O filtro é um teste de box, não um teste de visibilidade: texto escondido por um clipping path, pintado em branco ou coberto por uma imagem ainda é 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]));
    // todo glyph do content stream da página, slug line 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 emendado
    if Pdf.ExtractLoadedPageText(0, PageText) then
      Writeln(PageText);
  finally
    Pdf.Free;
  end;
end;

Texto pintado por Form XObjects faz parte do texto da página desde a v2.768.3. Headers, carimbos e marcas d'água muito frequentemente vivem em forms, e alguns documentos de padrões perdiam 30 a 35 por cento dos caracteres deles antes da mudança. O THotPDF.InterpretContentWithForms registra cada Do junto com o CTM em vigor, interpreta o form na /Matrix dele vezes esse CTM (§8.10.1), e emenda os glyphs do form na posição do Do, recursando em forms aninhados. Um form sem /Resources próprio toma emprestadas as do stream que o pinta, como a §7.8.3 permite. Glyphs de form carregam TokenIndex = -1, e o ExtractLoadedPageGlyphs ainda retorna só glyphs do stream da página, porque search, replace e redaction gravam mudanças de volta pelo TokenIndex e editariam os bytes errados se um glyph de form escorasse. Duas simplificações valem conhecer: texto de form não é cortado pela /BBox do form, e a recursão para em 12 níveis em vez de por detecção de ciclo, então um form malformado que pinta a si mesmo repete o texto dele até bater nesse teto

Quais glyphs o HotPDF inclui ao extrair texto de página de PDF em Delphi: o ExtractLoadedPageText mantém só glyphs cujo centro de box cai dentro do GetLoadedPageVisibleBox, a CropBox cortada pela MediaBox, então slug lines de impressora somem, enquanto o InterpretContentWithForms emenda glyphs de Form XObject em cada posição de Do com TokenIndex definido como -1 e a API de nível de glyph ainda retorna tudo
Um teste de box no centro do glyph não é um teste de visibilidade — texto branco, texto cortado e texto coberto ainda saem, e texto de form conta desde a v2.768.3

Por que texto depois de um operador Q decodificava como lixo?

Texto depois de Q podia decodificar errado antes da v2.766.73 porque o extrator guardava só o CTM no q. Os parâmetros de estado de texto, a saber fonte, tamanho, Tc, Tw, Tz, TL, modo de renderização e rise, pertencem ao graphics state (§9.3.1), então o Q precisa restaurá-los junto com todo 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 então mostrava texto WinAnsi de um byte sem Tf próprio. O extrator mantinha a fonte interna, lia os leaders e a palavra “Adobe” no sumário como códigos de dois bytes, e descartava 15% dos caracteres da página. A pilha de q/Q do interpretador agora guarda o estado de texto completo. As regras de extração descritas aqui valem para toda página, então um documento inteiro pode ir para um arquivo numa chamada só

var
  Output: TFileStream;
  Pages: Integer;
begin
  Output := TFileStream.Create('report.txt', fmCreate);
  try
    // faixa vazia = 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;

Qual API de texto do HotPDF você deve usar?

O ExtractLoadedPageText fica na ordem do content stream, que é o default certo para busca e indexação; a cadeia de decodificação por baixo dele é coberta em extrair texto de PDFs carregados com o HotPDF. Para documentos taggados cuja ordem de autoria importa, a extração de texto em ordem de estrutura caminha pela structure tree em vez de adivinhar pela geometria, e para dados presos em tabelas, a extração de tabelas tipada entre quebras de página retorna células em vez de linhas. Referência completa de API e download de teste estão na página de produto do HotPDF Delphi PDF Component