Odborný článok

Vyhľadávanie textu v Delphi PDF so súradnicami zásahov: PDFlibPas

Extrahovať text zo strany je tá jednoduchšia polovica problému. V okamihu, keď používateľ napíše slovo do vyhľadávacieho poľa a očakáva, že viewer naň skočí a nakreslí okolo neho žltý rámček, potrebujete niečo, čo plochý textový reťazec dať nevie: stranu, na ktorej sa každý zásah nachádza, a obdĺžnik, ktorý v PDF súradniciach zaberá. Reťazec zlepený cez celú stranu túto geometriu stratil. Podreťazec síce nájdete, ale neviete naň ukázať

PDFlibPas je natívna Object Pascal PDF knižnica pre Delphi a C++Builder a od verzie v3.78.0 odpovedá presne na túto otázku. Nad existujúcim extraktorom textových blokov sedia tri query API: SearchText prejde rozsah strán a vráti každý zásah s jeho stranou a axis-aligned rectangle, EnumPageElements vypíše všetko na jednej strane, textové bloky aj vložené obrázky, a GetTextInAreaEx hlási obdĺžnik každého bloku vo vnútri oblasti namiesto toho, aby ich sploštil na zoznam stringov. Ani jedno z týchto API sa nedotýka write path. Sú to čisto read-side rozšírenia nad mechanikou, ktorú knižnica už mala

Prečo geometria žije v zozname textových blokov, nie vo funneli

Prirodzený inštinkt je znovu použiť to, čo interne používa GetPageText. Táto cesta ide cez dočasný extrakčný "funnel", ktorý vytvorí textový reťazec strany a ešte pred návratom volania sa uvoľní. V čase, keď držíte výsledok, sú súradnice jednotlivých blokov preč. Nikdy neboli vaše na uchovanie

Súradnice však prežijú v inej štruktúre. ExtractPageTextBlocks(3) vracia handle zoznamu textových blokov, ktorého položky nesú bounding quad zložený z ôsmich double hodnôt, názov fontu, veľkosť fontu a text bloku. Tento handle je jediné miesto, kde sa geometria po extrakcii zachová, a práve preto sú všetky nové query API postavené na ňom, nie na funneli. Znovupoužitie zoznamu blokov znamená, že vyhľadávanie, enumerácia aj dotazy nad regiónom zdieľajú jeden extrakčný prechod a jednu definíciu toho, kde sa blok nachádza

Tvar SearchText z tohto obmedzenia priamo vyplýva. Pre každú stranu v rozsahu extrahuje zoznam blokov, načíta text každého bloku cez GetTextBlockText, otestuje ho proti dotazu a pre bloky, ktoré sedia, zredukuje quad na obdĺžnik. Vracaný zásah je malý záznam:

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;

Pole bound je prekladané X/Y, nie štyri rohy

Toto je detail, ktorý zvykne udrieť ako prvý. GetTextBlockBound(ListID, Index, BoundIndex) berie BoundIndex od 1 do 8 a týchto osem hodnôt nie je "roh 1, roh 2, roh 3, roh 4" po dvoch poliach, ako by sa dalo čakať. Sú to X, Y, X, Y, X, Y, X, Y: nepárne indexy sú X súradnice a párne indexy Y súradnice, spolu štyri body. Ak ich spárujete nesprávne, výsledný obdĺžnik je nezmysel

Dôvod, prečo vôbec existuje quad namiesto obyčajného obdĺžnika, je rotácia. Textový blok položený pod uhlom má skutočný ohraničujúci štvoruholník a tých osem double hodnôt ho verne opisuje. Pri použití typu zvýrazni a skoč však takmer vždy chcete vzpriamený box, preto knižnica zredukuje quad na axis-aligned rectangle tak, že prejde všetky štyri body a nájde ich minimálne a maximálne X a Y. Rotovaný text sa zbalí na vzpriamený box, ktorý ho uzatvára, a to je presne to, čo overlay zvýraznenia 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;

Všimnite si, že obdĺžnik je v PDF user-space bodoch s počiatkom v ľavom dolnom rohu strany, teda v tom istom súradnicovom systéme, ktorý odovzdávate kresliacim a annotation volaniam. Je to zámer: obdĺžnik, ktorý dostanete z výsledku vyhľadávania, môžete rovno odovzdať highlight annotation alebo príkazu typu "scroll here" bez akejkoľvek konverzie

Rozlišovanie veľkých písmen, whole-word a čím sa líši CJK

Druhý parameter je množina TPDFlibSearchOptions prevzatá z soCaseSensitive a soWholeWord. Prázdna množina [] je bežný prípad: case-insensitive substring search. Pridajte soCaseSensitive, aby sa Indemnity a indemnity považovali za rozdielne, pridajte soWholeWord, aby sa sign nenašlo vo vnútri signature, alebo skombinujte oboje

Whole-word porovnávanie potrebuje definíciu hranice slova a tu stojí za to ju pomenovať priamo, pretože je zámerne ASCII-centric. Znak sa považuje za súčasť slova, ak ide o ASCII písmeno, ASCII číslicu alebo underscore, teda triedu [A-Za-z0-9_] známu z pravidiel identifikátorov. Zhoda sa považuje za whole-word len vtedy, keď znaky bezprostredne pred ňou a za ňou nie sú znaky slova, alebo zhoda leží na hrane bloku

Dôsledok pre nelatinské skripty je dobré poznať skôr, než nasadíte viacjazyčné vyhľadávacie pole. Keďže čínske znaky, kana a iné ne-ASCII písmená spadajú mimo tejto triedy, každá hranica vedľa nich sa číta ako ne-slovná hrana. V praxi to znamená, že whole-word vyhľadávanie nad CJK textom sa správa tak, akoby každá pozícia bola platná hranica slova, takže sa tento príznak fakticky degraduje na substring matching. Je to zdokumentované obmedzenie, nie bug, a zodpovedá správaniu, podľa ktorého bola funkcia modelovaná. Ak je váš korpus primárne CJK, whole-word mód vám nedá segmentáciu, ktorú by poskytol špecializovaný tokenizer. Rátajte s tým a nespoliehajte sa naň

Jedna implementačná poznámka, ktorá vysvetľuje triedu jemných zlyhaní inde: case-insensitive porovnanie používa UpperCase nad WideString, nie AnsiUpperCase. ANSI varianta vracia AnsiString, čo by sa nerozchádzalo s WideString, ktoré používa zvyšok cesty, a ich miešanie vedie k typovým konfliktom a ešte horšie k stratovému foldingu znakov mimo aktívnej code page. Unicode dovnútra, Unicode von, po celej trase

Jeden parser rozsahov strán pre celú knižnicu

Tretí parameter je page range string ako "1,3,5-9". Na jeho parsovaní nie je nič vlastné: používa sa rovnaké PLParsePageRangeList, ktoré stojí za PrintPages a za rutinami na kopírovanie strán, takže rozsah, ktorý sa správne vytlačí, sa správne aj prehľadá. Prázdny reťazec rozsahu je sentinel pre "všetky strany" a v takom prípade SearchText zostaví celý zoznam samo

Rozsah má priamy dopad na cenu. Vyhľadávanie desaťstranového výseku z tisícstranového dokumentu extrahuje bloky pre desať strán, nie pre tisíc, pretože cyklus vyberá a extrahuje len tie strany, ktoré rozsah menuje. Keď už viete, že daná klauzula žije v prílohe, povedzte to aj rozsahu a preskočte zvyšok súboru

Interne vyhľadávanie aj enumerácia počas iterácie menia selected page, preto si každé z nich pri vstupe uloží aktuálne vybranú stranu a obnoví ju v bloku finally. Zavolajte SearchText uprostred skladania strany a po návrate budete mať selection presne tam, kde ste ju nechali. Tento kontrakt save-and-restore si človek všimne najmä vtedy, keď chýba, a práve preto tam je

Enumerácia celej strany: text aj obrázky v jednom zozname

Vyhľadávanie odpovedá na otázku "kde je toto slovo". Druhá polovica introspection je otázka "čo vôbec na tejto strane je" a na to slúži EnumPageElements. Vracia jeden jednotný zoznam, v ktorom je každý prvok buď textový blok, alebo vložený obrázok, rozlíšený poľom 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;

Textové prvky pochádzajú z rovnakého prechodu ExtractPageTextBlocks, takže každý už prichádza s vyplneným obdĺžnikom, názvom fontu a veľkosťou. Obrázkové prvky pochádzajú zo zoznamu vložených obrázkov strany cez FindImages a GetImageID; ich ImageID je handle, ktorý odovzdávate SelectImage na ďalšiu introspection obrázka. Obe kategórie pristávajú v jednom poli, takže jediným prechodom cez stranu vidíte všetko, čo na nej je

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;

Je tu counting convention, ktorá nasleduje zvyšok knižnice a ktorú musíte rešpektovať, inak budete čítať neinicializovanú pamäť. Návratová hodnota je celkový počet prvkov a môže byť väčší než pole, ktoré ste odovzdali. Funkcia vyplní len toľko slotov, koľko sa zmestí, a zvyšok ďalej počíta, presne ako enumerácia podpisov. Ochrana je teda vždy rovnaká: obmedzte cyklus na menšiu z hodnôt, teda návratový count a High(array), nikdy neiterujte slepo až po count. Vyššie uvedené príklady ukazujú kontrolu I <= High(...) práve preto. Ak návratová hodnota presiahne váš buffer, vytvorte väčšie pole a zavolajte funkciu znova

Ak ste už používali lower-level volania knižnice pre textové bloky, toto je typed vrstva s geometriou postavená nad nimi. Podkladová extrakcia je tá istá, aká je opísaná v článku Delphi PDF text, image a font extraction s PDFlibPas. A keď cieľom nie je "kde je tento text", ale "ako je dokument štruktúrovaný pre asistívnu technológiu", paralelným read-side príbehom je strom štruktúry tagged PDF, ktorý sprístupňuje logické poradie čítania, nie fyzické rozloženie blokov

Dotazy nad regiónom, keď už viete, kam sa pozrieť

Niekedy nemáte hľadaný výraz vôbec. Máte obdĺžnik. Šablóna formulára vždy umiestni číslo faktúry do pravého horného rohu alebo skenované rozloženie vyhradzuje pevný pás pre tabuľku. GetTextInAreaEx rieši práve tento prípad. Je to obdĺžnikový protipól k GetTextInArea: kým staršie volanie vracia pre región plochý zoznam stringov, nové volanie vracia spolu s textom aj obdĺžnik každého zachovaného bloku, takže sa dozviete nielen čo je v boxe, ale aj kde v ňom jednotlivé riadky sedia

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;

Treba si udržať v hlave dve veci. GetTextInAreaEx pracuje na aktuálne vybratej strane, takže najprv zavolajte SelectPage; na rozdiel od SearchText neberie rozsah. A blok sa zachová vtedy, keď sa s query rectangle pretína, nie len keď je v ňom celý obsiahnutý. Riadok, ktorý hranicu pretína, teda stále prejde. Pri ručne nakreslenom selection boxe je to zvyčajne to, čo chcete, ale ak potrebujete strict containment, môžete vrátené rectangles odfiltrovať sami, pretože ich teraz máte k dispozícii

Ako to uviesť do praxe

Spoločnou líniou všetkých troch volaní je, že geometria už nie je niečo, čo spätne rekonštruujete. Search hit pozná svoju stranu aj svoj box. Prvok strany pozná svoj obdĺžnik a pri texte aj font. Region query hlási, kde leží každý riadok. To stačí na vytvorenie skutočnej funkcie find-and-highlight, indexu click-to-locate alebo layout-aware extraktora bez toho, aby ste museli padnúť pod public API alebo ručne prestavať celý pipeline extrakcie textu

Tieto query API sa dodávajú ako súčasť PDFlibPas Delphi PDF Library, spolu s celou vrstvou extrakcie textových blokov, na ktorej sú postavené, a so zvyškom read-side introspection povrchu pre Delphi a C++Builder