Tehnički članak

Delphi PDF pretraga teksta sa koordinatama pogotka: PDFlibPas

Izdvajanje teksta sa strane je lakša polovina problema. Čim korisnik upiše reč u polje za pretragu i očekuje da pregledač skoči na nju i iscrta žuti okvir oko nje, potrebne su vam stvari koje običan tekstualni string ne može da pruži: strana na kojoj svaki pogodak stoji i pravougaonik koji zauzima u PDF koordinatama. String spojen preko strane izgubio je tu geometriju. Možete da nađete podniz, ali ne možete da ga pokažete prstom

PDFlibPas je nativna Object Pascal PDF biblioteka za Delphi i C++Builder, a od v3.78.0 odgovara baš na to pitanje. Tri API-ja za upit stoje iznad postojećeg izvlačenja tekstualnih blokova: SearchText prolazi kroz opseg strana i vraća svaki pogodak sa njegovom stranom i osno poravnatim pravougaonikom, EnumPageElements nabraja sve na jednoj strani (tekstualne blokove i ugrađene slike podjednako), a GetTextInAreaEx prijavljuje pravougaonik svakog bloka unutar regiona umesto da ih spljošti u listu stringova. Nijedan ne dodiruje putanju upisa; to su čisti read-side dodaci nad mašinerijom koju biblioteka već ima

Zašto geometrija živi u listi tekstualnih blokova, a ne u lijevku

Prirodni instinkt je da se ponovo iskoristi bilo koji GetPageText koji se interno pokreće. Ta putanja prolazi kroz prolazni ekstrakcioni "levak" koji proizvodi string strane i zatim se sam oslobađa pre nego što poziv vrati rezultat. Do trenutka kada držite rezultat, koordinati po bloku su nestale. Nikada nisu ni bile vaše za čuvanje

Koordinate ipak opstaju u drugoj strukturi. ExtractPageTextBlocks(3) vraća ručku liste tekstualnih blokova čiji elementi svaki nose osmo-dvostruki bounding quad, ime fonta, veličinu fonta i tekst bloka. Ta ručka je jedino mesto gde geometrija ostaje sačuvana posle ekstrakcije, zbog čega je svaki od novih query API-ja izgrađen na njoj umesto na levku. Ponovna upotreba liste blokova znači da pretraga, nabrajanje i region upiti dele jedan prolaz ekstrakcije i jednu definiciju gde se blok nalazi

Zato oblik SearchText sledi iz tog ograničenja. Za svaku stranu u opsegu on izvlači listu blokova, čita tekst svakog bloka preko GetTextBlockText, proverava ga u odnosu na upit, a za blokove koji se podudaraju svodi quad na pravougaonik. Pogodak koji vraća je mali zapis:

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;

Bound niz je X/Y isprepletan, a ne četiri ugla

Ovo je detalj koji prvi ujeda. GetTextBlockBound(ListID, Index, BoundIndex) uzima BoundIndex od 1 do 8, a tih osam vrednosti nisu "ugao 1, ugao 2, ugao 3, ugao 4" sa dva polja po grupi kako biste možda pogodili. One su X, Y, X, Y, X, Y, X, Y: neparni indeksi su X koordinate, parni indeksi su Y koordinate, ukupno četiri tačke. Ako ih čitate pogrešnim uparivanjem, vaš pravougaonik je besmislen

Razlog zašto quad uopšte postoji, umesto običnog pravougaonika, jeste rotacija. Tekstualni blok postavljen pod uglom ima pravi četvorotačkasti bounding poligon, a osam dvostrukih vrednosti ga verno opisuju. Za upotrebu isticanja i skoka obično želite uspravan okvir, pa biblioteka svodi quad na pravougaonik poravnat sa osama tako što pretražuje četiri tačke po njihovom minimumu i maksimumu X i Y. Rotirani tekst se sabija u uspravni okvir koji ga obuhvata, što je baš ono što highlight sloju treba:

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;

Imajte na umu da je pravougaonik u PDF user-space poentama sa koordinatnim početkom u donjem levom uglu strane, u istom koordinatnom sistemu koji prosleđujete pozivima za crtanje i anotacije. To je namerno: pravougaonik koji dobijete iz pogotka pretrage jeste pravougaonik koji možete odmah da date highlight anotaciji ili komandi "scroll here" bez ikakve konverzije

Osetljivost na velika i mala slova, cele reči i gde CJK odstupa

Drugi parametar je TPDFlibSearchOptions skup sastavljen od soCaseSensitive i soWholeWord. Prazan skup [] je uobičajen slučaj: pretraga podniska bez obzira na velika i mala slova. Dodajte soCaseSensitive da bi Indemnity i indemnity bili različiti, dodajte soWholeWord da biste sprečili sign da se poklapa unutar signature, ili kombinujte oba

Poklapanje cele reči traži definiciju šta je granica reči, a ovde je pravilo vredno reći otvoreno jer je po dizajnu ASCII-centrirano. Znak se računa kao deo reči kada je ASCII slovo, ASCII cifra ili donja crta: [A-Za-z0-9_] klasa poznata iz pravila za identifikatore. Pogodak se računa kao cela reč samo kada su znakovi neposredno pre i posle njega ne znak karaktera (ili se pogodak nalazi na ivici bloka)

Posledica za ne-latinične skripte je nešto što vredi znati pre nego što isporučite višenamensko polje za pretragu. Pošto Han znakovi, kana i druga ne-ASCII slova spadaju van te klase, svaka granica pored njih čita se kao ne-rečna ivica. U praksi to znači da pretraga cele reči nad CJK tekstom radi kao da je svaka pozicija važeća granica reči, pa se zastavica tamo efektivno svodi na pretragu podniska. To je dokumentovano ograničenje, ne bag, i poklapa se sa ponašanjem po uzoru na koje je funkcija modelovana. Ako je vaš korpus pretežno CJK, režim cele reči vam neće dati segmentaciju koju bi dao namenski tokenizer; planirajte oko toga umesto da se oslanjate na njega

Jedna napomena o implementaciji koja objašnjava čitavu klasu suptilnih grešaka drugde: poređenje neosetljivo na velika i mala slova koristi UpperCase nad WideString stringom, a ne AnsiUpperCase. Ansi varijanta vraća AnsiString, što se ne bi poklopilo sa WideString koje ostatak puta koristi, a mešanje ta dva proizvodi tip mismatch i, još gore, gubitničko preslikavanje za znakove van aktivne code page. Unicode unutra, Unicode napolju, sve do kraja

Jedan parser opsega strana za celu biblioteku

Treći parametar je string opsega strana kao što je "1,3,5-9". Tu nema ničeg prilagođenog u načinu parsiranja: isti PLParsePageRangeList koji pokreće PrintPages i rutine kopiranja strana ovde radi isto, pa opseg koji se štampa pravilno i pretražuje pravilno. Prazan string opsega je sentinel za "svaka strana", a u tom slučaju SearchText sam gradi celu listu

Opseg je važan i zbog cene. Pretraga desetostraničnog isečka iz dokumenta od hiljadu strana izvlači blokove za deset strana, a ne za hiljadu, jer petlja bira i izvlači samo strane koje opseg imenuje. Kada već znate da klauzula živi u dodatku, recite to u opsegu i preskočite ostatak fajla

Interno, pretraga i nabrajanje menjaju izabranu stranu dok iteriraju, pa svaka od njih čuva izabranu stranu pozivaoca pri ulasku i vraća je u finally blok-u. Pozovite SearchText usred sastavljanja strane i vaša selekcija je tačno tamo gde ste je ostavili kada se poziv vrati. Taj ugovor o čuvanju i vraćanju je upravo ono što primetite tek kada ga nema, i zato postoji

Nabrajanje cele strane: tekst i slike u jednoj listi

Pretraga odgovara na pitanje "gde je ova reč". Druga polovina introspekcije je "šta uopšte postoji na ovoj strani", a to je EnumPageElements. Ona vraća jednu objedinjenu listu u kojoj je svaki element ili tekstualni blok ili ugrađena slika, razlikovani po Kind polju:

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;

Tekstualni elementi dolaze iz istog ExtractPageTextBlocks prolaza, pa svaki stiže sa svojim pravougaonikom, nazivom fonta i veličinom već popunjenim. Elementi slike dolaze iz ugrađene liste slika strane preko FindImages i GetImageID; ImageID koji nose je ručka koju prosleđujete u SelectImage da biste sliku dalje pregledali. Dve vrste završavaju u jednom nizu tako da jedan prolaz kroz stranu vidi sve što je na njoj

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;

Ovde postoji konvencija brojanja koju ostatak biblioteke prati i koju morate da poštujete ili ćete čitati neinicijalizovanu memoriju. Povratna vrednost je ukupan broj elemenata, koji može biti veći od niza koji ste prosledili. Funkcija popunjava samo onoliko slotova koliko stane i nastavlja da broji ostale, baš kao što radi enumeracija potpisa. Zato je čuvar uvek isti: ograničite petlju na manju od vraćenog broja i High(array), nikada ne iterirajte slepo do count-a. Primeri gore prikazuju I <= High(...) proveru zbog toga. Ako vraćena vrednost premaši vaš bafer, napravite veći niz i pozovite ponovo

Ako ste koristili nižerangirane text-block pozive biblioteke, ovo je tipizovan, geometrijski svestan sloj iznad njih; osnovna ekstrakcija je ista ona opisana u Izdvajanje Delphi PDF teksta, slika i fontova uz PDFlibPas. A kada cilj nije "gde je ovaj tekst" nego "kako je ovaj dokument strukturiran za asistivne tehnologije", paralelna priča na strani čitanja je tagirano-PDF stablo strukture, koje izlaže logički redosled čitanja umesto fizičkog rasporeda blokova

Upiti po regionu kada već znate gde da gledate

Ponekad uopšte nemate pojam za pretragu; imate pravougaonik. Šablon obrasca uvek stavlja broj računa u gornji desni ugao ili skenirani raspored rezerviše fiksni pojas za tabelu. GetTextInAreaEx služi tom slučaju. To je pandan za bounds koji nosi GetTextInArea: gde stariji poziv vraća ravnu listu stringova za region, novi vraća pravougaonik svakog sačuvanog bloka zajedno sa njegovim tekstom, pa ne saznajete samo šta je u okviru nego i gde u okviru svaka linija stoji

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;

Dve stvari treba držati razdvojene. GetTextInAreaEx radi na trenutno izabranoj strani, pa prvo pozovite SelectPage; za razliku od SearchText, ne prima opseg. I blok se zadržava kada seče sa upitnim pravougaonikom, a ne samo kada je potpuno sadržan, pa i linija koja prelazi granicu i dalje prolazi. To je obično ono što želite za ručno nacrtani okvir selekcije, ali ako vam treba stroga sadržanost, možete sami da filtrirate vraćene pravougaonike, pošto ih sada imate

Primena u praksi

Zajednička nit kroz sva tri poziva je to što geometrija više nije nešto što rekonstrušete naknadno. Pogodak pretrage zna svoju stranu i svoj okvir. Element strane zna svoj pravougaonik i, za tekst, font. Upit po regionu prijavljuje gde svaka linija pada. To je dovoljno da se izgradi prava funkcija nalaženja i isticanja, indeks klikni-da-lociraš ili extractor koji je svestan rasporeda, bez spuštanja ispod javnog API-ja ili ručnog ponovnog izgrađivanja pipeline-a ekstrakcije teksta

Ovi query API-ji dolaze kao deo PDFlibPas Delphi PDF biblioteke, zajedno sa punim slojem ekstrakcije tekstualnih blokova na kome su izgrađeni i ostatkom read-side introspekcionog sloja za Delphi i C++Builder