Tehnični članak

Iskanje besedila PDF v Delphi s koordinatami zadetkov: PDFlibPas

Izločiti besedilo strani je lažja polovica problema. V trenutku, ko uporabnik v iskalno polje vnese besedo in pričakuje, da bo pregledovalnik skočil nanjo in okoli nje narisal rumen okvir, potrebujete nekaj, česar ploski besedilni niz ne more dati: stran, na kateri leži vsak zadetek, in pravokotnik, ki ga zasede v koordinatah PDF. Niz, zlepljen čez stran, je to geometrijo izgubil. Podniz lahko najdete, ne morete pa nanj pokazati

PDFlibPas je izvorna knjižnica PDF v Object Pascal za Delphi in C++Builder in od v3.78.0 odgovarja natanko na to vprašanje. Trije poizvedovalni API-ji sedijo nad obstoječim izločevalnikom besedilnih blokov: SearchText prehodi razpon strani in vrne vsak zadetek z njegovo stranjo in osno poravnanim pravokotnikom, EnumPageElements našteje vse na eni strani (besedilne bloke in vdelane slike), GetTextInAreaEx pa namesto sploščenja v seznam nizov poroča pravokotnik vsakega bloka znotraj regije. Nobeden od njih se ne dotakne poti zapisovanja; gre za čiste dodatke na strani branja nad mehanizmom, ki ga je knjižnica že imela

Zakaj geometrija živi v seznamu besedilnih blokov, ne v lijaku

Naravni instinkt je ponovno uporabiti karkoli interno poganja GetPageText. Ta pot gre skozi prehodni ekstrakcijski "lijak", ki ustvari niz strani in se nato, še preden se klic vrne, sprosti. Ko držite rezultat v rokah, koordinat po blokih ni več. Nikoli niso bile vaše, da bi jih obdržali

Koordinate preživijo v drugi strukturi. ExtractPageTextBlocks(3) vrne handle seznama besedilnih blokov, katerega elementi nosijo omejitveni quad z osmimi vrednostmi double, ime pisave, velikost pisave in besedilo bloka. Ta handle je edino mesto, kjer se geometrija po ekstrakciji ohrani, zato je vsak novi poizvedovalni API zgrajen na njem in ne na lijaku. Ponovna uporaba seznama blokov pomeni, da si iskanje, enumeracija in poizvedbe po regijah delijo en prehod ekstrakcije in eno definicijo, kje blok je

Iz te omejitve sledi oblika SearchText. Za vsako stran v razponu izvleče seznam blokov, prebere besedilo vsakega bloka z GetTextBlockText, ga preizkusi proti poizvedbi in pri blokih, ki se ujemajo, quad zreducira na pravokotnik. Zadetek, ki ga vrne, je majhen 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;

Polje bound je izmenično X/Y, ne štirje vogali

To je podrobnost, ki ugrizne najprej. GetTextBlockBound(ListID, Index, BoundIndex) sprejme BoundIndex od 1 do 8, teh osem vrednosti pa niso "vogal 1, vogal 2, vogal 3, vogal 4" z združenima poljema, kot bi morda ugibali. So X, Y, X, Y, X, Y, X, Y: lihi indeksi so koordinate X, sodi indeksi so koordinate Y, skupaj štiri točke. Če jih združite napačno, dobite nesmiseln pravokotnik

Razlog, da sploh obstaja quad in ne navaden pravokotnik, je rotacija. Besedilni blok, postavljen pod kotom, ima resničen omejitveni poligon s štirimi točkami in teh osem double ga zvesto opiše. Za primer uporabe "označi in skoči" skoraj vedno želite navpičen okvir, zato knjižnica quad zvede na osno poravnan pravokotnik tako, da čez vse štiri točke preleti po minimumu in maksimumu X in Y. Rotirano besedilo se sesede na navpičen okvir, ki ga obdaja, kar je natanko tisto, kar prekrivni poudarek potrebuje:

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;

Upoštevajte, da je pravokotnik v točkah uporabniškega prostora PDF z izhodiščem v spodnjem levem kotu strani, torej v istem koordinatnem sistemu, ki ga podate risalnim klicem in klicem za anotacije. To je namerno: pravokotnik, ki ga dobite iz zadetka iskanja, lahko brez kakršnekoli pretvorbe neposredno predate anotaciji za poudarek ali ukazu "pomakni sem"

Občutljivost na velikost črk, cele besede in kje se CJK razlikuje

Drugi parameter je množica TPDFlibSearchOptions, sestavljena iz soCaseSensitive in soWholeWord. Prazna množica [] je pogost primer: iskanje podniza brez razlikovanja velikosti črk. Dodajte soCaseSensitive, da bosta Indemnity in indemnity različna, dodajte soWholeWord, da preprečite, da bi se sign ujemal znotraj signature, ali pa združite oboje

Ujemanje celih besed potrebuje definicijo meje besede in tukaj je pravilo vredno neposredne izjave, ker je namenoma osredinjeno na ASCII. Znak šteje kot del besede, kadar je črka ASCII, števka ASCII ali podčrtaj: razred [A-Za-z0-9_], znan iz pravil za identifikatorje. Ujemanje šteje za celo besedo samo takrat, ko znaki neposredno pred njim in za njim niso besedni znaki (ali pa ujemanje leži na robu bloka)

Posledica za nelatinične pisave je nekaj, kar morate vedeti, preden pošljete večjezično iskalno polje v produkcijo. Ker pismenke Han, kana in druge ne-ASCII črke padejo zunaj tega razreda, se vsaka meja ob njih bere kot rob ne-besede. V praksi to pomeni, da se iskanje celih besed po besedilu CJK vede, kot da je vsak položaj veljavna meja besede, zato se zastavica tam dejansko zreducira na iskanje podniza. To je dokumentirana omejitev, ne hrošč, in se ujema z vedenjem, po katerem je bila funkcija modelirana. Če je vaš korpus pretežno CJK, vam način celih besed ne bo dal segmentacije, ki bi jo dal namenski tokenizer; načrtujte okoli tega, namesto da bi se nanj zanašali

Ena implementacijska opomba, ki pojasni razred subtilnih odpovedi drugje: primerjava brez razlikovanja velikosti črk uporablja UpperCase na WideString, ne pa AnsiUpperCase. Različica Ansi vrne AnsiString, kar se ne bi ujemalo z WideString, ki ga uporablja preostala pot, mešanje obeh pa povzroči neusklajenost tipov in, še huje, izgubljajoče prelamljanje znakov zunaj aktivne kodne strani. Unicode noter, Unicode ven, od začetka do konca

En parser razponov strani za vso knjižnico

Tretji parameter je niz razpona strani, kot je "1,3,5-9". V načinu razčlenjevanja ni nič posebnega: isti PLParsePageRangeList, ki podpira PrintPages in rutine kopiranja strani, ga obravnava tudi tukaj, zato razpon, ki pravilno tiska, pravilno išče. Prazen niz razpona je signal za "vsaka stran", v tem primeru pa SearchText sam sestavi celoten seznam

Obseg je pomemben za strošek. Iskanje po desetstranskem izseku tisočstranskega dokumenta izvleče bloke za deset strani, ne za tisoč, ker zanka izbira in obdeluje samo strani, ki jih razpon poimenuje. Če že veste, da se klavzula nahaja v dodatku, to povejte v razponu in preskočite preostalo datoteko

Interno tako iskanje kot enumeracija med iteriranjem spreminjata izbrano stran, zato si vsaka ob vstopu shrani klicateljevo izbrano stran in jo v bloku finally obnovi. Pokličite SearchText sredi gradnje strani in vaša izbira bo po vrnitvi klica natančno tam, kjer ste jo pustili. Takšna pogodba shrani-in-obnovi je stvar, ki jo opazite šele, ko je ni, in prav zato je tam

Naštevanje cele strani: besedilo in slike v enem seznamu

Iskanje odgovori na vprašanje "kje je ta beseda." Druga polovica introspekcije pa je "kaj je sploh na tej strani," in to je EnumPageElements. Vrne enoten seznam, kjer je vsak element bodisi besedilni blok bodisi vdelana slika, ločen po polju Kind:

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;

Besedilni elementi prihajajo iz istega prehoda ExtractPageTextBlocks, zato vsak od njih že prispe z izpolnjenim pravokotnikom, imenom pisave in velikostjo. Slikovni elementi prihajajo iz seznama vdelanih slik strani prek FindImages in GetImageID; ImageID, ki ga nosijo, je handle, ki ga podate SelectImage, če želite sliko podrobneje pregledati. Obe vrsti pristanejo v enem polju, tako da en sam sprehod po strani vidi vse na njej

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;

Tu obstaja konvencija štetja, ki sledi preostali knjižnici in jo morate spoštovati, sicer boste brali neinicializiran pomnilnik. Povratna vrednost je skupno število elementov, ki je lahko večje od polja, ki ste ga podali. Funkcija zapolni samo toliko mest, kolikor jih gre, in nadaljuje s štetjem preostalih, popolnoma enako kot enumeracija podpisov. Varovalka je zato vedno enaka: svojo zanko omejite na manjše od vrnjenega števila in High(array), nikoli slepo ne iterirajte do vrnjenega števila. Zgornji primeri prav zato kažejo preverjanje I <= High(...). Če povratna vrednost presega vaš medpomnilnik, ustvarite večje polje in pokličite znova

Če ste uporabljali nižjenivojske klice knjižnice za besedilne bloke, je to tipizirana plast z geometrijo nad njimi; osnovna ekstrakcija je ista, kot je opisana v izločanju besedila, slik in pisav PDF v Delphi z orodjem PDFlibPas. In kadar cilj ni "kje je to besedilo" temveč "kako je ta dokument strukturiran za podporno tehnologijo," je vzporedna zgodba na strani branja strukturno drevo označenega PDF, ki izpostavi logični vrstni red branja namesto fizične postavitve blokov

Poizvedbe po regiji, ko že veste, kam gledati

Včasih sploh nimate iskalnega izraza; imate pravokotnik. Predloga obrazca vedno postavi številko računa v zgornji desni kot ali pa skenirana postavitev rezervira fiksni pas za tabelo. GetTextInAreaEx služi prav temu primeru. Je različica GetTextInArea, ki nosi omejitve: kjer starejši klic za regijo vrne ploski seznam nizov, novi vrne pravokotnik vsakega ohranjenega bloka skupaj z njegovim besedilom, tako da ne izveste samo, kaj je v okvirju, ampak tudi, kje znotraj njega leži vsaka vrstica

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;

Paziti morate na dve stvari. GetTextInAreaEx deluje na trenutno izbrani strani, zato najprej pokličite SelectPage; za razliko od SearchText ne sprejme razpona. In blok se ohrani takrat, ko se seka s poizvedbenim pravokotnikom, ne samo takrat, ko je v njem v celoti, zato bo skozi prišla tudi vrstica, ki prečka mejo. To je običajno tisto, kar želite pri ročno narisanem izbirnem okvirju, če pa potrebujete strogo vsebovanost, lahko vrnjene pravokotnike zdaj, ko jih imate, sami filtrirate

Kako to spraviti v delo

Skupna nit vseh treh klicev je, da geometrija ni več nekaj, kar rekonstruirate za nazaj. Zadetek iskanja pozna svojo stran in svoj okvir. Element strani pozna svoj pravokotnik in pri besedilu svojo pisavo. Poizvedba po regiji poroča, kam pade vsaka vrstica. To zadošča za gradnjo resnične funkcije poišči-in-poudari, kazala s klikom-na-lokacijo ali ekstraktorja, ki razume postavitev, ne da bi se spuščali pod javni API ali ročno znova sestavljali cevovod za izločanje besedila

Ti poizvedovalni API-ji so del PDFlibPas Delphi PDF Library, skupaj s celotno plastjo za ekstrakcijo besedilnih blokov, na kateri so zgrajeni, in preostalo introspekcijsko površino na strani branja za Delphi in C++Builder