Articol tehnic

Extragere text PDF în Delphi: spații și sfârșituri de linie

HotPDF Delphi Component reconstruiește spațiile dintre cuvinte și liniile noi în THotPDF.ExtractLoadedPageText din geometria glyph-urilor, nu din caracterele spațiu. Un spațiu intră când golul de după lățimea proprie a unui glyph depășește 0.15 din înălțimea textului, iar o linie nouă pornește doar când originea textului se mută peste direcția de scriere cu mai mult de jumătate din înălțimea textului. Din v2.768.3, textul paginii include și textul desenat prin Form XObjects și lasă afară glyph-urile din afara zonei vizibile de crop. Restul materialului explică de ce fiecare regulă arată cum arată, pentru că fiecare dintre ele a înlocuit o regulă mai simplă care producea output plauzibil dar greșit pe documente reale

Simptomele le cunoaște oricine a băgat text PDF într-un index de căutare. O copertă se extrage ca PDFReferenceManualNovember4,1998, un formular fiscal se sparge în 156 de linii, un watermark diagonal sosește câte un caracter pe linie, iar o probă tăiată începe cu linia de slug a imprimantei pe care niciun viewer n-o arată vreodată. Niciunul dintre aceste fișiere nu e stricat. Fiecare folosește un mod perfect legal de a plasa textul, pe care un extractor naiv îl citește greșit

De ce pierde textul PDF extras spațiile dintre cuvinte?

Textul extras pierde spațiile pentru că unui PDF nu i se cere niciodată să le conțină. Un producător poate separa cuvintele afișând un caracter spațiu, dar poate la fel de bine muta penița cu un număr dintr-un tablou TJ (ISO 32000-1 §9.4.3) sau cu un Td proaspăt (§9.4.2), iar output-ul TeX, multe fișiere Distiller și majoritatea machetelor justify fac exact asta. Înainte de v2.766.76, HPDFAssemblePageText se uita doar la mișcarea verticală, deci o rupere de cuvânt făcută prin poziționare pur și simplu dispărea. Assembler-ul măsoară acum, de-a lungul direcției de scriere a glyph-ului precedent, distanța de la capătul lățimii proprii a acelui glyph până la originea glyph-ului curent și inserează un spațiu când distanța depășește 0.15 din înălțimea cutiei glyph-ului curent, măsurată de la ascendent la descendent în user space. Nu se adaugă spațiu când oricare dintre părți e deja gol și nici între două caractere CJK, pentru că justify-ul depărtează ideogramele fără ca despărțirea aceea să însemne o limită de cuvânt. Înregistrările de glyph expun aceeași geometrie, deci puteți reproduce decizia când un anumit fișier vă pune la încercare

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
    // înălțimea ascendent-descendent a cutiei glyph-ului, în user space
    Height := Sqrt(Sqr(Glyphs[I].QuadX[3] - Glyphs[I].QuadX[0]) +
      Sqr(Glyphs[I].QuadY[3] - Glyphs[I].QuadY[0]));
    // text orizontal: golul de la capătul lățimii proprii a glyph-ului precedent
    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;

De ce măsura de la lățimea proprie a glyph-ului și nu de la poziția peniței?

HotPDF măsoară golurile dintre cuvinte de la GlyphEndX / GlyphEndY pentru că poziția peniței de după un glyph conține deja spațiere care nu e gol. ISO 32000-1 §9.4.4 definește deplasarea orizontală ca lățimea glyph-ului ori dimensiunea fontului, plus spațierea de caractere Tc, plus spațierea de cuvinte Tw, totul scalat de Tz. BaselineEndX / BaselineEndY țin deplasarea completă, în vreme ce GlyphEndX / GlyphEndY țin doar avansul fontului și Tz. Diferența contează pentru producătorii care strâng tracking-ul cu un Tc negativ și apoi îi dau înapoi spațiul printr-o ajustare TJ după fiecare glyph: măsurat de la poziția peniței, datul înapoi arată ca un gol, iar termenul chinezesc “95后” se extrăgea ca “9 5 后”. Pragul e legat de înălțimea cutiei glyph-ului și nu de dimensiunea Tf-ului dintr-un motiv asemănător. Exporturile Word scriu adesea 1 Tf și cară dimensiunea reală într-un Tm scalat, deci Tfs spune 1 în timp ce textul are 10 puncte, iar o regulă legată de Tfs ar trata cele două ortografii ale aceleiași pagini diferit

Regula de spațiu de cuvinte din HotPDF pentru ExtractLoadedPageText în Delphi: un spațiu e inserat doar când distanța de la GlyphEndX-ul glyph-ului precedent la BaselineStartX-ul următorului depășește 0.15 din înălțimea ascendent-descendent a cutiei, fiindcă poziția peniței din BaselineEndX conține deja Tc, Tw și Tz și transformă datele înapoi de tracking justify în goluri false precum 9 5 后
Geometria, nu caracterele spațiu, decide unde se rup cuvintele — înregistrările de glyph expun aceleași măsurători, deci puteți rejuca decizia pentru orice fișier enigmatic

Regula are margini cinstite. Un titlu compus cu tracking foarte larg, unde singur Tc deschide mai mult de 0.15 din înălțimea textului între litere, se extrage cu un spațiu între fiecare litere, ceea ce e exact cum arată pagina, dar probabil nu ce voiați să indexați. Porțiunile desenate dezordonat pe aceeași baseline produc un gol negativ și se alătură fără spațiu. Niciunul dintre cazuri nu e comun în textul de corp, iar pe un corpus de test schimbarea a ridicat potrivirile de cuvinte față de un extractor de referință pe 28 de pagini fără să coboare vreuna

Când pornește HotPDF o linie nouă în textul extras?

Din v2.766.79, o linie nouă pornește când mutarea de la originea glyph-ului precedent la cea curentă, proiectată pe normala direcției de scriere precedente, depășește jumătate din înălțimea mai mare de cutie a celor două glyph-uri. Regula veche compara mișcarea brută pe Y cu jumătate din Tfs, ceea ce eșua în două direcții. Cu 1 Tf și un Tm scalat, pragul se micșora la jumătate de unitate, deci un exponent ridicat printr-un text rise de 0.4 sau jitter-ul obișnuit al baseline-ului rupea linia. Regula ignora și X cu desăvârșire, deci textul sub un Tm rotit cobora prin pagină cu fiecare glyph și ieșea câte un glyph pe linie. Proiectarea pe normala direcției face ca rulările rotite să se poarte ca cele orizontale, iar luarea celei mai mari din cele două înălțimi ține un cuvânt de probă mare și legenda lui mică pe o singură linie când partajează baseline-ul. Pe formularul fiscal menționat mai sus, numărul de linii a coborât de la 156 la 97. Textul vertical în writing mode 1 (§9.7.4.3) urmează o cale separată: glyph-urile acelea sunt grupate în coloane, citite de la dreapta la stânga și de sus în jos, cu o linie nouă la fiecare schimbare de coloană

Cum decide ExtractLoadedPageText din HotPDF liniile noi în Delphi: mutarea dintre originile glyph-urilor e proiectată pe normala direcției de scriere și comparată cu jumătate din înălțimea mai mare de cutie, astfel încât un exponent ridicat printr-un text rise mic sub un font 1 Tf și textul care coboară prin pagină sub un Tm rotit nu se mai sparg câte un glyph pe linie
Proiectarea face ca rulările rotite să se poarte ca cele orizontale, iar luarea celei mai mari din cele două înălțimi de cutie ține un cuvânt de probă mare și legenda lui mică pe o singură linie

Ce text include și ce lasă afară ExtractLoadedPageText?

ExtractLoadedPageText întoarce textul pe care îl arată un viewer. Din v2.766.80 lucrează doar din glyph-urile vizibile, aruncând fiecare glyph al cărui centru de cutie cade în afara lui GetLoadedPageVisibleBox, adică CropBox-ul tăiat de MediaBox (§14.11.2). Asta elimină liniile de slug și celelalte mărci de imprimantă compuse ca text în afara zonei de tăiere. ExtractLoadedPageGlyphs continuă în mod deliberat să întoarcă fiecare glyph al content stream-ului paginii, deci materialul acela rămâne de găsit când aveți nevoie de el. Filtrul e un test de cutie, nu un test de vizibilitate: textul ascuns de o cale de decupare, desenat în alb sau acoperit de o imagine se extrage în continuare

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]));
    // fiecare glyph al content stream-ului paginii, linia de slug inclusă
    if Pdf.ExtractLoadedPageGlyphs(0, Glyphs) then
      Writeln(Length(Glyphs), ' glyphs in the page content stream');
    // doar ce arată pagina, cu textul din Form XObject inserat
    if Pdf.ExtractLoadedPageText(0, PageText) then
      Writeln(PageText);
  finally
    Pdf.Free;
  end;
end;

Textul desenat prin Form XObjects face parte din textul paginii din v2.768.3. Header-ele, ștampilele și watermark-urile trăiesc foarte des în forme, iar unele documente de standarde pierdeau 30-35% din caractere înainte de schimbare. THotPDF.InterpretContentWithForms înregistrează fiecare Do împreună cu CTM-ul în vigoare, interpretează forma la /Matrix-ul ei ori CTM-ul acela (§8.10.1) și inserează glyph-urile formei la poziția lui Do, recursând în formele imbricate. O formă fără /Resources proprii împrumută cele ale stream-ului care o desenează, cum permite §7.8.3. Glyph-urile de formă cară TokenIndex = -1, iar ExtractLoadedPageGlyphs întoarce în continuare doar glyph-urile din stream-ul paginii, pentru că căutarea, înlocuirea și redactarea scriu schimbările înapoi prin TokenIndex și ar edita octeții greșiți dacă un glyph de formă s-ar strecura. Două simplificări merită știute: textul formei nu e decupat la /BBox-ul formei, iar recursia se oprește la 12 niveluri în loc de detecție de cicluri, deci o formă malformată care se desenează pe sine își repetă textul până ajunge la plafonul acela

Ce glyph-uri include HotPDF la extragerea textului din pagini PDF în Delphi: ExtractLoadedPageText păstrează doar glyph-urile al căror centru de cutie cade în GetLoadedPageVisibleBox, CropBox-ul tăiat de MediaBox, deci liniile de slug ale imprimantei dispar, în timp ce InterpretContentWithForms inserează glyph-urile Form XObject la fiecare poziție Do cu TokenIndex setat pe -1, iar API-ul la nivel de glyph întoarce în continuare totul
Un test de cutie pe centrul glyph-ului nu e un test de vizibilitate — textul alb, cel decupat și cel acoperit ies în continuare, iar textul din forme contează din v2.768.3

De ce textul de după un operator Q se decoda ca baliverne?

Textul de după Q putea fi decodat greșit înainte de v2.766.73 pentru că extractor-ul salva doar CTM-ul la q. Parametrii stării de text, adică fontul, dimensiunea, Tc, Tw, Tz, TL, modul de randare și ridicarea, aparțin stării grafice (§9.3.1), deci Q trebuie să-i restituie împreună cu tot restul de pe stivă (§8.4.2). Un raport de industrie alegea un font Identity-H pe doi octeți în interiorul unui q … Q și apoi arăta text WinAnsi pe un octet fără un Tf propriu. Extractor-ul ținea fontul din interior, citea liniile-puncte și cuvântul “Adobe” din cuprins ca coduri pe doi octeți și arunca 15% din caracterele paginii. Stiva q/Q a interpretului ține acum starea completă de text. Regulile de extragere descrise aici se aplică fiecărei pagini, deci un document întreg poate ajunge într-un fișier într-un singur apel

var
  Output: TFileStream;
  Pages: Integer;
begin
  Output := TFileStream.Create('report.txt', fmCreate);
  try
    // interval gol = toate paginile; form feed între pagini; BOM UTF-8
    Pages := Pdf.ExtractLoadedPagesTextToStream(Output, '', #12, True);
    Writeln(Pages, ' pages extracted');
  finally
    Output.Free;
  end;
end;

Ce API de text HotPDF ar trebui să folosiți?

ExtractLoadedPageText rămâne în ordinea content stream-ului, care e implicitul corect pentru căutare și indexare; lanțul de decodare de sub el e acoperit în extragerea textului din PDF-uri încărcate cu HotPDF. Pentru documentele etichetate în care ordinea de autorare contează, extragerea de text în ordinea structurii parcurge arborele de structură în loc să ghicească din geometrie, iar pentru datele prinse în tabele, extragerea tipizată de tabele peste sfârșituri de pagină întoarce celule, nu linii. Referința completă API și o descărcare de încercare sunt pe pagina de produs HotPDF Delphi PDF Component