Articolo tecnico

Estrarre testo PDF in Delphi: spazi tra parole e a capo

HotPDF Delphi Component ricostruisce spazi tra parole e a capo in THotPDF.ExtractLoadedPageText a partire dalla geometria dei glyph, non dai caratteri spazio. Uno spazio va dentro quando il gap dopo la larghezza propria di un glyph supera 0.15 dell'altezza del testo, e una nuova riga inizia solo quando l'origine del testo si muove attraverso la direzione di scrittura di più di metà dell'altezza del testo. Dalla v2.768.3 il testo di pagina include anche il testo dipinto attraverso Form XObjects e lascia fuori i glyph fuori dall'area di crop visibile. Il resto di questo pezzo spiega perché ogni regola appare come appare, perché ognuna ha sostituito una regola più semplice che produceva output plausibile ma sbagliato su documenti reali

I sintomi sono familiari a chiunque abbia mandato testo PDF in un indice di ricerca. Una copertina si estrae come PDFReferenceManualNovember4,1998, un modulo fiscale si spezza in 156 righe, un watermark diagonale arriva un carattere per riga, e una prova ritagliata comincia con la slug line della stampante che nessun viewer mostra mai. Nessuno di questi file è rotto. Ognuno usa un modo perfettamente legale di piazzare il testo che un estrattore ingenuo legge male

Perché il testo PDF estratto perde i suoi spazi tra parole?

Il testo estratto perde gli spazi tra parole perché a un PDF non è mai richiesto contenerli. Un producer può separare le parole mostrando un carattere spazio, ma può anche muovere la penna con un numero dentro un array TJ (ISO 32000-1 §9.4.3) o con un nuovo Td (§9.4.2), e l'output di TeX, molti file Distiller e la maggior parte dei layout giustificati fanno esattamente questo. Prima della v2.766.76, HPDFAssemblePageText guardava solo il movimento verticale, quindi un'interruzione di parola fatta con il posizionamento semplicemente svaniva. L'assembler ora misura, lungo la direzione di scrittura del glyph precedente, la distanza dalla fine della larghezza propria di quel glyph all'origine del glyph corrente, e inserisce uno spazio quando la distanza supera 0.15 dell'altezza del box del glyph corrente, misurata dall'ascent al descent nello user space. Nessuno spazio viene aggiunto quando uno dei due lati è già bianco, e nessuno tra due caratteri CJK, perché la giustificazione allarga gli ideogrammi senza che quell'allargamento significhi confine di parola. I record dei glyph espongono la stessa geometria, quindi puoi riprodurre la decisione quando un file particolare ti lascia perplesso

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
    // altezza ascent-to-descent del box del glyph, in user space
    Height := Sqrt(Sqr(Glyphs[I].QuadX[3] - Glyphs[I].QuadX[0]) +
      Sqr(Glyphs[I].QuadY[3] - Glyphs[I].QuadY[0]));
    // testo orizzontale: gap dalla fine della larghezza propria del glyph precedente
    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;

Perché misurare dalla larghezza propria del glyph invece che dalla posizione della penna?

HotPDF misura i gap tra parole da GlyphEndX / GlyphEndY perché la posizione della penna dopo un glyph contiene già spaziatura che non è un gap. ISO 32000-1 §9.4.4 definisce lo spostamento orizzontale come la larghezza del glyph per la dimensione del font, più la character spacing Tc, più il word spacing Tw, tutto scalato per Tz. BaselineEndX / BaselineEndY contengono quello spostamento completo, mentre GlyphEndX / GlyphEndY contengono solo l'avanzamento del font e Tz. La differenza conta per i producer che stringono il tracking con un Tc negativo e poi ridanno lo spazio attraverso una correzione TJ dopo ogni glyph: misurato dalla posizione della penna, la restituzione sembra un gap, e il termine cinese «95后» si estraeva come «9 5 后». La soglia è legata all'altezza del box del glyph piuttosto che alla dimensione Tf per una ragione simile. Gli export di Word spesso scrivono 1 Tf e portano la dimensione vera in una Tm scalata, quindi Tfs dice 1 mentre il testo è alto 10 punti, e una regola agganciata a Tfs tratterebbe diversamente le due grafie della stessa pagina

La regola dello word space di HotPDF per ExtractLoadedPageText in Delphi: uno spazio viene inserito solo quando la distanza dal GlyphEndX del glyph precedente al BaselineStartX del glyph successivo supera 0.15 dell'altezza del box ascent-to-descent, perché la posizione della penna in BaselineEndX contiene già Tc, Tw e Tz e trasforma le restituzioni di tracking giustificato in falsi gap come 9 5 后
La geometria, non i caratteri spazio, decide dove le parole si spezzano — i record dei glyph espongono le stesse misure, quindi puoi rigiocare la decisione per qualsiasi file enigmatico

La regola ha bordi onesti. Un titolo composto con tracking molto largo, dove il solo Tc apre più di 0.15 dell'altezza del testo tra le lettere, si estrae con uno spazio tra ogni lettera, che è ciò che la pagina mostra ma probabilmente non ciò che volevi indicizzare. Pezzi disegnati fuori ordine sulla stessa baseline producono un gap negativo e si uniscono senza spazio. Nessuno dei due casi è comune nel corpo del testo, e su un corpus di test la modifica ha alzato i match di parola contro un estrattore di riferimento su 28 pagine senza abbassarne nessuno

Quando HotPDF inizia una nuova riga nel testo estratto?

Dalla v2.766.79, una nuova riga inizia quando lo spostamento dall'origine del glyph precedente a quello corrente, proiettato sulla normale della direzione di scrittura precedente, supera metà dell'altezza di box più grande dei due glyph. La regola precedente confrontava lo spostamento Y grezzo con metà di Tfs, il che falliva in due direzioni. Con 1 Tf e una Tm scalata la soglia si riduceva a mezza unità, quindi un apice alzato da un text rise di 0.4 o un normale tremolio di baseline spezzava la riga. La regola inoltre ignorava del tutto la X, quindi testo sotto una Tm ruotata scendeva lungo la pagina di un glyph alla volta e usciva un glyph per riga. Proiettare sulla normale della direzione fa comportare le sequenze ruotate come quelle orizzontali, e prendere la più grande delle due altezze tiene una parola campione grande e la sua piccola didascalia su una riga quando condividono la baseline. Sul modulo fiscale citato sopra, il conteggio delle righe è sceso da 156 a 97. Il testo verticale in writing mode 1 (§9.7.4.3) segue un percorso separato: quei glyph vengono raggruppati in colonne, letti da destra a sinistra e dall'alto in basso, con un a capo a ogni cambio di colonna

Come ExtractLoadedPageText di HotPDF decide gli a capo in Delphi: lo spostamento tra le origini dei glyph viene proiettato sulla normale della direzione di scrittura e confrontato con metà dell'altezza di box più grande, così un apice alzato da un piccolo text rise sotto un font 1 Tf e testo che scende lungo la pagina sotto una Tm ruotata non si spezzano più in un glyph per riga
La proiezione fa comportare le sequenze ruotate come quelle orizzontali, e prendere la più grande delle due altezze di box tiene una parola campione grande e la sua piccola didascalia su una riga

Quale testo include o lascia fuori ExtractLoadedPageText?

ExtractLoadedPageText restituisce il testo che un viewer mostra. Dalla v2.766.80 lavora solo sui glyph visibili, scartando ogni glyph il cui centro del box cade fuori da GetLoadedPageVisibleBox, che è il CropBox ritagliato sul MediaBox (§14.11.2). Questo elimina le slug line e gli altri segni della stampante composti come testo fuori dall'area di taglio. ExtractLoadedPageGlyphs continua deliberatamente a restituire ogni glyph del content stream della pagina, quindi quel materiale puoi comunque trovarlo quando ti serve. Il filtro è un test a box, non un test di visibilità: testo nascosto da un clipping path, dipinto in bianco o coperto da un'immagine viene comunque estratto

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]));
    // ogni glyph del content stream della pagina, slug line inclusa
    if Pdf.ExtractLoadedPageGlyphs(0, Glyphs) then
      Writeln(Length(Glyphs), ' glyphs in the page content stream');
    // solo ciò che la pagina mostra, con il testo dei Form XObject innestato
    if Pdf.ExtractLoadedPageText(0, PageText) then
      Writeln(PageText);
  finally
    Pdf.Free;
  end;
end;

Il testo dipinto attraverso Form XObjects fa parte del testo di pagina dalla v2.768.3. Header, timbri e watermark vivono molto spesso nelle form, e alcuni documenti di normative perdevano dal 30 al 35 percento dei propri caratteri prima della modifica. THotPDF.InterpretContentWithForms registra ogni Do insieme alla CTM in vigore, interpreta la form alla sua /Matrix per quella CTM (§8.10.1), e innesta i glyph della form nella posizione del Do, ricorrendo nelle form annidate. Una form senza un proprio /Resources prende in prestito quelle dello stream che la dipinge, come consente §7.8.3. I glyph delle form portano TokenIndex = -1, e ExtractLoadedPageGlyphs restituisce ancora solo i glyph dello stream di pagina, perché search, replace e redaction riscrivono le modifiche attraverso TokenIndex e modificherebbero i byte sbagliati se un glyph di form si infiltrasse. Due semplificazioni valgono la pena di essere conosciute: il testo delle form non viene ritagliato sul /BBox della form, e la ricorsione si ferma a 12 livelli invece che con il rilevamento dei cicli, quindi una form malformata che dipinge se stessa ripete il suo testo finché raggiunge quel tetto

Quali glyph include HotPDF quando estrae il testo di pagina PDF in Delphi: ExtractLoadedPageText conserva solo i glyph il cui centro del box cade dentro GetLoadedPageVisibleBox, il CropBox ritagliato sul MediaBox, quindi le slug line delle stampanti svaniscono, mentre InterpretContentWithForms innesta i glyph dei Form XObject a ogni posizione Do con TokenIndex a -1 e l'API a livello glyph restituisce ancora tutto
Un test a box sul centro del glyph non è un test di visibilità — testo bianco, testo ritagliato e testo coperto escono comunque, e il testo delle form conta dalla v2.768.3

Perché il testo dopo un Q si decodificava come spazzatura?

Il testo dopo Q poteva decodificarsi male prima della v2.766.73 perché l'estrattore salvava sulla q solo la CTM. I parametri di stato testo, cioè font, dimensione, Tc, Tw, Tz, TL, render mode e rise, appartengono allo stato grafico (§9.3.1), quindi Q deve ripristinarli insieme a tutto il resto sullo stack (§8.4.2). Un report di settore selezionava un font two-byte Identity-H dentro q … Q e poi mostrava testo WinAnsi one-byte senza un Tf proprio. L'estrattore conservava il font interno, leggeva i puntini e la parola «Adobe» sull'indice come codici two-byte, e buttava il 15 percento dei caratteri della pagina. Lo stack q/Q dell'interprete ora contiene lo stato testo completo. Le regole di estrazione descritte qui valgono per ogni pagina, quindi un intero documento può andare su file in una sola chiamata

var
  Output: TFileStream;
  Pages: Integer;
begin
  Output := TFileStream.Create('report.txt', fmCreate);
  try
    // intervallo vuoto = tutte le pagine; form feed tra le pagine; BOM UTF-8
    Pages := Pdf.ExtractLoadedPagesTextToStream(Output, '', #12, True);
    Writeln(Pages, ' pages extracted');
  finally
    Output.Free;
  end;
end;

Quale API di testo HotPDF dovresti usare?

ExtractLoadedPageText resta nell'ordine del content stream, che è il default giusto per search e indicizzazione; la catena di decodifica sotto di essa è trattata in estrarre testo da PDF caricati con HotPDF. Per i documenti con tag dove l'ordine di composizione conta, l'estrazione del testo in ordine di struttura percorre l'albero di struttura invece di indovinare dalla geometria, e per i dati chiusi nelle tabelle, l'estrazione tipizzata delle tabelle oltre gli a capo di pagina restituisce celle piuttosto che righe. Riferimento API completo e download di prova sono sulla pagina di prodotto di HotPDF Delphi PDF Component