Articol tehnic

Căutare de text PDF în Delphi cu coordonatele rezultatelor: PDFlibPas

Extragerea textului unei pagini este jumătatea ușoară a problemei. În clipa în care un utilizator tastează un cuvânt într-o casetă de căutare și se așteaptă ca vizualizatorul să sară la el și să traseze o casetă galbenă în jurul lui, ai nevoie de ceva ce un șir de text plat nu îți poate oferi: pagina pe care se află fiecare potrivire și dreptunghiul pe care îl ocupă în coordonate PDF. Un șir concatenat pe toată pagina și-a pierdut acea geometrie. Poți găsi subșirul, dar nu poți indica exact unde este

PDFlibPas este o bibliotecă PDF nativă Object Pascal pentru Delphi și C++Builder, iar de la v3.78.0 răspunde exact la această întrebare. Trei API-uri de interogare stau deasupra extractorului existent de blocuri de text: SearchText parcurge un interval de pagini și returnează fiecare rezultat cu pagina și dreptunghiul aliniat la axe, EnumPageElements listează tot ce se află pe o pagină (atât blocuri de text, cât și imagini încorporate), iar GetTextInAreaEx returnează dreptunghiul fiecărui bloc dintr-o regiune în loc să le aplatizeze într-o listă de șiruri. Niciuna dintre ele nu atinge calea de scriere; sunt adăugiri pure pe partea de citire peste mecanismele pe care biblioteca le avea deja

De ce geometria rămâne în lista de blocuri de text, nu în pâlnie

Instinctul natural este să refolosești orice GetPageText rulează intern. Acea cale trece printr-o „pâlnie” de extragere efemeră care produce șirul paginii și apoi se eliberează înainte să se întoarcă apelul. În momentul în care ai rezultatul în mână, coordonatele fiecărui bloc au dispărut. Niciodată nu au fost ale tale de păstrat

Coordonatele supraviețuiesc totuși într-o altă structură. ExtractPageTextBlocks(3) returnează un handle de listă de blocuri de text ale cărui elemente poartă fiecare un quad de încadrare din opt valori double, un nume de font, o dimensiune de font și textul blocului. Acest handle este singurul loc în care geometria este păstrată după extragere, motiv pentru care fiecare dintre noile API-uri de interogare este construit pe el, nu pe pâlnie. Refolosirea listei de blocuri înseamnă că căutarea, enumerarea și interogările pe regiune împart o singură trecere de extragere și o singură definiție a locului în care se află un bloc

Așadar, forma lui SearchText rezultă din această constrângere. Pentru fiecare pagină din interval, extrage lista de blocuri, citește textul fiecărui bloc cu GetTextBlockText, îl testează față de interogare, iar pentru blocurile care se potrivesc reduce quad-ul la un dreptunghi. Rezultatul returnat este o înregistrare mică:

type
  TPDFlibSearchHit = record
    Page: Integer;                       // 1-based page of the match
    Left, Top, Right, Bottom: Double;    // axis-aligned hit rectangle
    MatchText: WideString;               // the block text that contained the query
  end;

Tabloul limitelor este intercalat X/Y, nu patru colțuri

Acesta este detaliul care te mușcă primul. GetTextBlockBound(ListID, Index, BoundIndex) ia un BoundIndex de la 1 la 8, iar acele opt valori nu sunt „colțul 1, colțul 2, colțul 3, colțul 4”, cu două câmpuri pentru fiecare grupate împreună așa cum ai putea crede. Ele sunt X, Y, X, Y, X, Y, X, Y: indicii impari sunt coordonate X, indicii pari sunt coordonate Y, în total patru puncte. Citește-le în perechea greșită și dreptunghiul tău devine fără sens

Motivul pentru care există deloc un quad, în loc de un simplu dreptunghi, este rotația. Un bloc de text setat la un unghi are un poligon real de încadrare cu patru puncte, iar cele opt valori double îl descriu fidel. Pentru cazul de evidențiere și salt, de cele mai multe ori vrei în schimb o casetă dreaptă, așa că biblioteca reduce quad-ul la un dreptunghi aliniat la axe, măturând cele patru puncte pentru valorile lor minime și maxime X și Y. Textul rotit se prăbușește într-o casetă dreaptă care îl înconjoară, exact ce are nevoie o suprapunere de evidențiere:

var
  Pdf: TPDFlib;
  Hits: array[0..255] of TPDFlibSearchHit;
  Found, I: Integer;
begin
  Pdf := TPDFlib.Create(nil);
  try
    Pdf.LoadFromFile('contract.pdf', '');
    // Search pages 1 to 10, case-insensitive, substring match.
    Found := Pdf.SearchText('indemnity', [], '1-10', Hits);
    for I := 0 to Found - 1 do
      if I <= High(Hits) then
        WriteLn(Format('p%d: [%.1f %.1f %.1f %.1f] %s',
          [Hits[I].Page, Hits[I].Left, Hits[I].Top,
           Hits[I].Right, Hits[I].Bottom, Hits[I].MatchText]));
  finally
    Pdf.Free;
  end;
end;

Observă că dreptunghiul este în puncte ale spațiului utilizatorului PDF, cu originea în colțul stânga-jos al paginii, același sistem de coordonate pe care îl transmiți către apelurile de desen și adnotare. Asta este intenționat: dreptunghiul pe care îl primești înapoi dintr-un rezultat de căutare este dreptunghiul pe care îl poți transmite direct unei adnotări de evidențiere sau unei comenzi „scroll aici” fără să convertești nimic

Sensibilitate la majuscule, cuvinte întregi și unde diferă CJK

Al doilea parametru este un TPDFlibSearchOptions set derivat din soCaseSensitive și soWholeWord. Mulțimea goală [] este cazul obișnuit: o căutare de subșir insensibilă la majuscule. Adaugă soCaseSensitive ca să faci Indemnity și indemnity distincte, adaugă soWholeWord ca să oprești sign de la potrivirea în interiorul lui signature, sau combină-le pe amândouă

Potrivirea pe cuvânt întreg are nevoie de o definiție pentru ce este un hotar de cuvânt, iar aici regula merită spusă clar pentru că este centrată pe ASCII prin concepție. Un caracter contează ca parte dintr-un cuvânt atunci când este o literă ASCII, o cifră ASCII sau un underscore: clasa [A-Za-z0-9_] familiară din regulile pentru identificatori. O potrivire se califică drept cuvânt întreg numai când caracterele imediat dinainte și după ea sunt nu caractere de cuvânt (sau potrivirea se află la marginea blocului)

Consecința pentru scripturile non-latine este ceva ce trebuie știut înainte să lansezi o casetă de căutare multilingvă. Deoarece caracterele Han, kana și alte litere non-ASCII ies în afara acestei clase, fiecare limită de lângă ele este citită ca o margine non-cuvânt. În practică, asta înseamnă că o căutare după cuvânt întreg peste text CJK se comportă ca și cum fiecare poziție ar fi o limită validă de cuvânt, astfel că indicatorul se degradează efectiv la o potrivire de subșir acolo. Aceasta este o limitare documentată, nu o eroare, și corespunde comportamentului după care a fost modelată funcționalitatea. Dacă corpusul tău este în principal CJK, modul cu cuvânt întreg nu îți va da segmentarea pe care ar oferi-o un tokenizer dedicat; planifică în jurul acestui lucru, nu te baza pe el

O notă de implementare care explică o clasă de eșecuri subtile în altă parte: comparația insensibilă la majuscule și minuscule folosește UpperCase pe WideString, nu AnsiUpperCase. Varianta Ansi returnează un AnsiString, care nu s-ar potrivi cu WideString pe care îl folosește restul traseului, iar amestecarea celor două produce nepotriviri de tip și, mai rău, normalizări cu pierderi pentru caracterele din afara paginii de cod active. Unicode intră, Unicode iese, până la capăt

Un singur parser de intervale de pagini pentru întreaga bibliotecă

Al treilea parametru este un șir de intervale de pagini precum "1,3,5-9". Nu există nimic personalizat în felul în care este analizat: același PLParsePageRangeList care stă la baza PrintPages și a rutinelor de copiere a paginilor îl gestionează și aici, așa că un interval care se tipărește corect se caută corect. Un șir de intervale gol este sentinelul pentru „fiecare pagină”, caz în care SearchText construiește singur lista completă

Scopul influențează costul. Căutarea unui segment de zece pagini dintr-un document de o mie de pagini extrage blocuri pentru zece pagini, nu pentru o mie, deoarece bucla selectează și extrage doar paginile numite de interval. Când știi deja că o clauză se află în anexă, spune asta în interval și sari peste restul fișierului

La nivel intern, căutarea și enumerarea schimbă pagina selectată pe măsură ce iterează, așa că fiecare salvează pagina selectată a apelantului la intrare și o restaurează într-un finally bloc. Apelează SearchText în mijlocul construirii unei pagini și selecția ta va fi exact unde ai lăsat-o când apelul se întoarce. Acest contract de salvare și restaurare este genul de lucru pe care îl observi doar când lipsește, tocmai de aceea există

Enumerarea unei pagini întregi: text și imagini într-o singură listă

Căutarea răspunde la „unde este acest cuvânt”. Cealaltă jumătate a introspecției este „ce se află, de fapt, pe această pagină”, iar aceasta este EnumPageElements. Întoarce o singură listă unificată în care fiecare element este fie un bloc de text, fie o imagine încorporată, diferențiate de un Kind câmp:

type
  TPDFlibPageElementKind = (ekText, ekImage);

  TPDFlibPageElement = record
    Kind: TPDFlibPageElementKind;
    Page: Integer;
    Left, Top, Right, Bottom: Double;
    Text: WideString;        // ekText
    FontName: WideString;    // ekText
    FontSize: Double;        // ekText
    ImageID: Integer;        // ekImage; usable with SelectImage / GetImageID
  end;

Elementele de text vin din aceeași parcurgere ExtractPageTextBlocks, astfel că fiecare ajunge cu dreptunghiul său, numele fontului și dimensiunea deja completate. Elementele de imagine vin din lista de imagini încorporate a paginii prin FindImages și GetImageID; ImageID identificatorul pe care îl poartă este handle-ul pe care îl trimiți la SelectImage pentru a inspecta imaginea mai departe. Cele două tipuri ajung într-un singur tablou, astfel încât o singură parcurgere a paginii vede tot ce se află pe ea

var
  Pdf: TPDFlib;
  Elems: array[0..511] of TPDFlibPageElement;
  Total, I: Integer;
begin
  Pdf := TPDFlib.Create(nil);
  try
    Pdf.LoadFromFile('report.pdf', '');
    Total := Pdf.EnumPageElements(1, Elems);
    for I := 0 to Total - 1 do
      if I <= High(Elems) then
        if Elems[I].Kind = ekText then
          WriteLn(Format('text  %s/%.1f  "%s"',
            [Elems[I].FontName, Elems[I].FontSize, Elems[I].Text]))
        else
          WriteLn(Format('image id=%d', [Elems[I].ImageID]));
  finally
    Pdf.Free;
  end;
end;

Există aici o convenție de numărare care urmează restul bibliotecii și pe care trebuie s-o respecți, altfel vei citi memorie neinițializată. Valoarea returnată este totalul elementelor, care poate fi mai mare decât tabloul pe care l-ai transmis. Funcția completează doar atâtea poziții câte încap și continuă să numere restul, exact cum funcționează enumerarea semnăturilor. Așadar, regula de protecție este mereu aceeași: limitează bucla la valoarea mai mică dintre numărul returnat și High(array), nu itera niciodată orbește până la număr. Exemplele de mai sus arată verificarea I <= High(...) pentru acest motiv. Dacă valoarea returnată depășește bufferul tău, dimensionează un tablou mai mare și apelează din nou

Dacă ai folosit apelurile de nivel inferior ale bibliotecii pentru blocuri de text, acesta este stratul tipizat, conștient de geometrie, deasupra lor; extragerea de bază este aceeași ca în Extragerea textului, imaginilor și fonturilor din PDF în Delphi cu PDFlibPas. Iar când scopul nu este „unde este acest text”, ci „cum este structurat acest document pentru tehnologiile asistive”, povestea paralelă pe partea de citire este arborele de structură tagged-PDF, care expune ordinea logică de citire, nu aspectul fizic al blocurilor

Uneori nu ai deloc un termen de căutare; ai un dreptunghi. Un șablon de formular pune mereu numărul facturii în colțul din dreapta sus, sau un layout scanat rezervă o bandă fixă pentru un tabel. GetTextInAreaExse potrivește pentru acel caz. Este omologul cu limite al GetTextInArea: unde apelul vechi întoarce o listă plată de șiruri pentru o regiune, cel nou returnează dreptunghiul fiecărui bloc păstrat împreună cu textul lui, așa că afli nu doar ce este în cutie, ci și unde stă fiecare linie în ea

var
  Pdf: TPDFlib;
  Hits: array[0..63] of TPDFlibSearchHit;
  Found, I: Integer;
begin
  Pdf := TPDFlib.Create(nil);
  try
    Pdf.LoadFromFile('invoice.pdf', '');
    Pdf.SelectPage(1);
    // Left, Top, Width, Height in PDF points on the selected page.
    Found := Pdf.GetTextInAreaEx(360, 720, 180, 60, Hits);
    for I := 0 to Found - 1 do
      if I <= High(Hits) then
        WriteLn(Hits[I].MatchText);
  finally
    Pdf.Free;
  end;
end;

Două lucruri de ținut minte.GetTextInAreaEx funcționează pe pagina selectată curent, așa că apelează SelectPage mai întâi; spre deosebire de SearchText, nu primește un interval. Iar un bloc este păstrat când intersects dreptunghiul interogării, nu doar când este cuprins în întregime, așa că o linie care trece peste limită apare în continuare. Asta este, de obicei, exact ce vrei pentru un chenar de selecție desenat manual, dar dacă ai nevoie de o cuprindere strictă poți filtra singur dreptunghiurile returnate, fiindcă acum le ai

Cum îl pui la lucru

Firul comun al tuturor celor trei apeluri este că geometria nu mai este ceva ce reconstruiești după fapt. Un rezultat de căutare își cunoaște pagina și caseta. Un element de pagină își cunoaște dreptunghiul și, pentru text, fontul. O interogare de regiune raportează unde cade fiecare linie. Asta este suficient pentru a construi o funcție reală de căutare și evidențiere, un index de localizare la clic sau un extractor care ține cont de aspectul paginii, fără să cobori sub API-ul public și fără să reconstruiești manual fluxul de extragere a textului

Aceste API-uri de interogare sunt livrate ca parte din PDFlibPas Delphi PDF Library, împreună cu stratul complet de extragere a blocurilor de text pe care se bazează și cu restul suprafeței de introspecție de pe partea de citire pentru Delphi și C++Builder