Tehnički članak

Delphi PDF pretraživanje teksta s koordinatama pogotka: PDFlibPas

Ekstrakcija teksta stranice je laka polovica problema. U trenutku kada korisnik upiše riječ u polje za pretraživanje i očekuje da preskoči do nje i iscrta žuti okvir oko nje, treba vam nešto što vam ravni tekst ne može dati: stranicu na kojoj je svaki pogodak, i pravokutnik koji zauzima u PDF koordinatama. Niz spojen preko stranice izgubio je tu geometriju. Substring možete pronaći, ali ne možete pokazati na njega

PDFlibPas je nativna Object Pascal PDF biblioteka za Delphi i C++Builder, a od v3.78.0 odgovara upravo na to pitanje. Tri query API-ja sjede nad postojećim extractorom tekstualnih blokova: SearchTextSearchText prolazi raspon stranica i vraća svaki pogodak s njegovom stranicom i osi poravnatim pravokutnikom, EnumPageElementsEnumPageElements nabraja sve na jednoj stranici (tekstualne blokove i ugrađene slike podjednako), a GetTextInAreaExGetTextInAreaEx prijavljuje pravokutnik svakog bloka unutar regije umjesto da ih spljoštava u listu stringova. Nijedan ne dira write path; to su čista read-side proširenja nad mehanikom koju biblioteka već ima

Zašto geometrija živi u popisu tekstualnih blokova, a ne u funnelu

Prirodni je instinkt ponovno upotrijebiti što god GetPageTextGetPageText radi interno. Taj put prolazi kroz prijelazni extraction "funnel" koji proizvodi string stranice i zatim se sam oslobađa prije nego se poziv vrati. Do trenutka kad držite rezultat, koordinate po bloku su nestale. Nikad nisu ni bile vaše za zadržati

Koordinate ipak opstaju u drugoj strukturi. ExtractPageTextBlocks(3)ExtractPageTextBlocks(3) vraća handle liste tekstualnih blokova čiji elementi svaki nose osmerostruki bounding quad, ime fonta, veličinu fonta i tekst bloka. Taj handle je jedino mjesto gdje je geometrija zadržana nakon ekstrakcije, zbog čega je svaki od novih query API-ja izgrađen na njemu, a ne na funnelu. Ponovna uporaba block liste znači da pretraga, enumeracija i upiti po području dijele jedan extraction pass i jednu definiciju gdje je blok

Dakle, oblik SearchText slijedi iz tog ograničenja. Za svaku stranicu u rasponu izvlači listu blokova, čita tekst svakog bloka s SearchTextGetTextBlockTextGetTextBlockText, testira ga prema upitu i za blokove koji se podudaraju reducira quad u pravokutnik. Pogodak koji vraća mali je 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;

Vezani niz je X/Y isprepleten, a ne četiri kuta

Ovo je detalj koji prvi ugrize. GetTextBlockBound(ListID, Index, BoundIndex)GetTextBlockBoundBoundIndex uzima BoundIndex od 1 do 8, a tih osam vrijednosti nisu "kut 1, kut 2, kut 3, kut 4" s po dva polja grupirana zajedno kako biste možda pretpostavili. One su X, Y, X, Y, X, Y, X, Y: neparni indeksi su X koordinate, parni indeksi su Y koordinate, ukupno četiri točke. Pročitate li ih u pogrešnom sparivanju, vaš je pravokutnik besmislica

Razlog zašto quad uopće postoji, umjesto običnog pravokutnika, jest rotacija. Tekstualni blok postavljen pod kutom ima pravi četverotočkasti poligon obuhvata, a osam double vrijednosti to vjerno opisuju. Za slučaj isticanja i skoka gotovo uvijek želite uspravan okvir, pa biblioteka reducira quad na osi poravnat pravokutnik tako da pregleda četiri točke za njihov minimum i maksimum X i Y. Rotirani tekst kolabira u uspravni okvir koji ga obuhvaća, a to je upravo ono što treba highlight overlayu:

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 pravokutnik u PDF user-space točkama s ishodištem u donjem lijevom kutu stranice, istim koordinatnim sustavom koji predajete crtanju i anotacijskim pozivima. To je namjerno: pravokutnik koji dobijete natrag iz search pogotka pravokutnik je koji možete predati izravno highlight anotaciji ili naredbi "scroll here" bez ikakve pretvorbe

Osjetljivost na velika slova, cijele riječi i gdje CJK odstupa

Drugi parametar je TPDFlibSearchOptions set nacrtan iz soCaseSensitive i soWholeWord. Prazan skup [] je uobičajen slučaj: case-insensitive substring pretraga. Dodajte soCaseSensitive da bi Indemnity i indemnity bili različiti, dodajte soWholeWord da biste zaustavili sign da se podudara unutar signature, ili kombinirajte oboje

Whole-word podudaranje traži definiciju što je granica riječi, a ovdje vrijedi pravilo koje treba izričito reći jer je po dizajnu ASCII centričan. Znak se računa kao dio riječi kad je ASCII slovo, ASCII znamenka ili podvlaka: [A-Za-z0-9_] klasa poznata iz pravila identifikatora. Pogodak se kvalificira kao whole-word samo kada znakovi neposredno prije i poslije njega nisu word znakovi (ili se pogodak nalazi na rubu bloka).ne word znakovi (ili se pogodak nalazi na rubu bloka)

Posljedica za nelatinične skripte vrijedna je znati prije nego što isporučite višejezični okvir za pretraživanje. Budući da Han znakovi, kana i druga ne-ASCII slova padaju izvan te klase, svaka granica uz njih čita se kao ne-word rub. U praksi to znači da whole-word pretraga nad CJK tekstom ponaša se kao da je svaka pozicija valjana granica riječi, pa se zastavica tamo zapravo svodi na substring matching. To je dokumentirano ograničenje, ne bug, i odgovara ponašanju na kojem je značajka modelirana. Ako vam je korpus pretežno CJK, whole-word mode neće dati segmentaciju koju bi dao namjenski tokenizer; planirajte oko toga umjesto da se oslanjate na njega

Jedna implementacijska fusnota koja objašnjava klasu suptilnih kvarova drugdje: case-insensitive usporedba koristi UpperCase na WideString, a ne AnsiUpperCase. Ansi varijanta vraća AnsiString, koja se ne bi poravnala s WideString koje ostatak puta koristi, a miješanje te dvije stvara tip mismatch i, još gore, gubitničko skupljanje znakova izvan aktivne code page. Unicode unutra, Unicode van, cijelim putem

Jedan parser page rangea za cijelu biblioteku

Treći parametar je page range string kao "1,3,5-9". Tu nema ničega prilagođenog u načinu parsiranja: isti PLParsePageRangeList koji pokreće PrintPages i rutine za kopiranje stranica to obrađuje i ovdje, pa raspon koji se ispravno ispisuje i ispravno pretražuje. Prazan string raspona je sentinel za "svaka stranica", u kojem slučaju SearchText gradi puni popis sam

Opseg je važan za trošak. Pretraživanje desetstraničnog isječka tisućustraničnog dokumenta izvlači blokove za deset stranica, a ne tisuću, jer petlja bira i izvlači samo stranice koje raspon imenuje. Kad već znate da se članak nalazi u prilogu, recite to u rasponu i preskočite ostatak datoteke

Interno, pretraga i enumeracija mijenjaju odabranu stranicu dok iteriraju, pa svaka spremi trenutno odabranu stranicu pri ulasku i vraća je u finally block. Pozovete li SearchText usred gradnje stranice, vaš odabir je točno ondje gdje ste ga ostavili kada se poziv vrati. Taj ugovor save-and-restore je stvar koju primijetite tek kada nedostaje, a upravo zato postoji

Nabrajanje cijele stranice: tekst i slike na jednom popisu

Search odgovara na "gdje je ova riječ". Druga polovica introspekcije je "što se na ovoj stranici uopće nalazi", i to je EnumPageElements. 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 dolazi s pravokutnikom, imenom fonta i veličinom već popunjenim. Elementi slika dolaze iz popisa ugrađenih slika stranice putem FindImages i GetImageID; ImageID koje nose je handle koji gurnete u SelectImage da biste sliku dalje pregledali. Dvije vrste završavaju u jednom nizu tako da jedan prolaz po stranici 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;

Tu postoji brojilačka konvencija koja slijedi ostatak biblioteke i koju morate poštovati ili ćete čitati neinicijaliziranu memoriju. Povratna vrijednost je ukupni broj elemenata, koji može biti veći od niza koji ste predali. Funkcija popunjava samo onoliko slotova koliko stane i nastavlja brojati ostatak, točno kao što radi enumeracija potpisa. Zato je zaštita uvijek ista: ograničite petlju na manju od vraćenog broja i High(array), nikad ne iterirajte do counta naslijepo. Primjeri gore pokazuju I <= High(...) provjeru iz tog razloga. Ako povratna vrijednost prelazi vaš buffer, dodijelite veći niz i pozovite ponovno

Ako ste koristili niže razine text-block poziva, ovo je tipizirani sloj s geometrijskom sviješću iznad njih; underlying extraction je isti onaj opisan u Delphi PDF text, image, and font extraction with PDFlibPas. A kad cilj nije "gdje je ovaj tekst" nego "kako je ovaj dokument strukturiran za asistivnu tehnologiju", paralelna read-side priča je tagged-PDF structure tree, koji izlaže logički redoslijed čitanja umjesto fizičkog rasporeda blokova

Upiti po području kad već znate gdje gledati

Ponekad uopće nemate pojavni pojam; imate pravokutnik. Predložak obrasca uvijek stavlja broj računa u gornji desni kut, ili skenirani layout rezervira fiksnu traku za tablicu. GetTextInAreaExGetTextInAreaEx serves that case. It is the bounds-carrying counterpart of GetTextInArea: where the older call hands back a flat list of strings for a region, the new one returns each retained block's rectangle alongside its text, so you learn not just what is in the box but where inside it each line sits

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;

Dvije stvari držite ravno. GetTextInAreaExGetTextInAreaEx works on the currently selected page, so call SelectPage first; unlike SearchText, it does not take a range. And a block is kept when it intersects the query rectangle, not only when it is fully contained, so a line that straddles boundary still comes through. That is usually what you want for a hand-drawn selection box, but if you need strict containment you can filter the returned rectangles yourself, since you now have them

Staviti u rad

Zajednička nit kroz sva tri poziva jest da geometrija više nije nešto što rekonstruirate naknadno. Search hit zna svoju stranicu i okvir. Page element zna svoj pravokutnik i, za tekst, font. Region query prijavljuje gdje svaka linija pada. To je dovoljno da se izgradi pravi find-and-highlight feature, click-to-locate indeks ili layout-aware extractor bez spuštanja ispod javnog API-ja ili ručne izgradnje pipelinea za ekstrakciju teksta

Ovi query API-ji dolaze kao dio PDFlibPas Delphi PDF Library, uz puni sloj ekstrakcije tekstualnih blokova na kojem se temelje i ostatak read-side introspection surface za Delphi i C++Builder