Techninis straipsnis

PDF teksto paieška Delphi aplinkoje su atitikmenų koordinatėmis: PDFlibPas

Ištraukti puslapio tekstą yra lengvesnioji problemos pusė. Kai tik naudotojas paieškos laukelyje įrašo žodį ir tikisi, kad peržiūros programa prie jo nušoks bei aplink jį nubrėš geltoną rėmelį, prireikia to, ko plokščia teksto eilutė duoti negali: puslapio, kuriame yra kiekvienas atitikmuo, ir stačiakampio, kurį jis užima PDF koordinatėse. Iš viso puslapio sujungta eilutė tą geometriją jau yra praradusi. Poaibį rasti galite, bet parodyti į jį negalite

PDFlibPas yra gimtoji Object Pascal PDF biblioteka, skirta Delphi ir C++Builder, ir nuo v3.78.0 ji atsako būtent į šį klausimą. Virš esamo teksto blokų ištraukiklio atsirado trys užklausų API: SearchText pereina per puslapių diapazoną ir grąžina kiekvieną atitikmenį su jo puslapiu bei ašims lygiu stačiakampiu, EnumPageElements išvardija viską viename puslapyje, ir teksto blokus, ir įterptus vaizdus, o GetTextInAreaEx vietoje suplokštinimo į eilučių sąrašą pateikia kiekvieno regiono viduje esančio bloko stačiakampį. Nė viena iš jų neliečia įrašymo kelio; tai gryni skaitymo pusės papildymai ant mechanikos, kurią biblioteka jau turėjo

Kodėl geometrija gyvena teksto blokų sąraše, o ne piltuve

Natūralus instinktas yra pakartotinai naudoti tai, ką viduje vykdo GetPageText. Tas kelias eina per laikiną ištraukimo „piltuvą“, kuris sukuria puslapio eilutę ir prieš grįžtant iš kvietimo pats save atlaisvina. Kai jau laikote rezultatą rankoje, kiekvieno bloko koordinatės būna dingusios. Jos niekada nebuvo paliktos jums saugoti

Koordinatės išlieka kitoje struktūroje. ExtractPageTextBlocks(3) grąžina teksto blokų sąrašo identifikatorių, kurio kiekvienas elementas turi aštuonių double reikšmių ribojantį keturkampį, šrifto pavadinimą, šrifto dydį ir paties bloko tekstą. Tas identifikatorius yra vienintelė vieta, kur geometrija po ištraukimo dar lieka, todėl kiekviena nauja užklausų API kuriama ant jo, o ne ant piltuvo. Pakartotinai naudodami blokų sąrašą gaunate tai, kad paieška, enumeravimas ir regiono užklausos dalijasi vienu ištraukimo praėjimu ir vienu apibrėžimu, kur blokas yra

Todėl SearchText forma tiesiogiai išplaukia iš šio apribojimo. Kiekvienam puslapiui diapazone jis ištraukia blokų sąrašą, kiekvieno bloko tekstą perskaito per GetTextBlockText, palygina jį su užklausa, o blokams, kurie atitinka, sumažina keturkampį iki stačiakampio. Grąžinamas atitikmuo yra mažas įrašas:

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;

Ribų masyvas yra persipynęs X/Y, o ne keturi kampai

Čia slypi pirmasis skaudus niuansas. GetTextBlockBound(ListID, Index, BoundIndex) priima BoundIndex nuo 1 iki 8, o šios aštuonios reikšmės nėra „1 kampas, 2 kampas, 3 kampas, 4 kampas“, kur kiekvienam jų būtų priskirtos dvi reikšmės taip, kaip galėtumėte atspėti. Jos eina seka X, Y, X, Y, X, Y, X, Y: nelyginiai indeksai yra X koordinatės, lyginiai - Y koordinatės, iš viso keturi taškai. Suporavę jas neteisingai gausite visišką nesąmonę vietoje stačiakampio

Priežastis, kodėl apskritai naudojamas keturkampis, o ne paprastas stačiakampis, yra pasukimas. Kampu padėtas teksto blokas turi tikrą keturių taškų ribojantį daugiakampį, ir aštuonios double reikšmės jį aprašo tiksliai. Paryškinimo ir šuolio scenarijui beveik visada norite ne jo, o statiško stačiakampio, todėl biblioteka sumažina keturkampį iki ašims lygaus stačiakampio, peržvelgdama keturis taškus ir paimdama jų mažiausią bei didžiausią X ir Y. Pasuktas tekstas susiveda į jį apgaubiantį stačią rėmą, o būtent to ir reikia paryškinimo perdangai:

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;

Atkreipkite dėmesį, kad stačiakampis yra PDF vartotojo erdvės taškuose, kurių koordinatės pradedamos nuo puslapio apatinio kairiojo kampo, tai yra toje pačioje sistemoje, kurią perduodate piešimo ir anotacijų kvietimams. Tai padaryta sąmoningai: stačiakampį, kurį gaunate iš paieškos atitikmens, galite tiesiogiai paduoti paryškinimo anotacijai arba komandai „slinkti čia“ ir nieko neperkonvertuoti

Raidžių dydis, whole-word režimas ir kuo skiriasi CJK

Antrasis parametras yra TPDFlibSearchOptions aibė, sudaryta iš soCaseSensitive ir soWholeWord. Tuščia aibė [] yra dažniausias atvejis: poeilutės paieška, neskirianti raidžių dydžio. Pridėkite soCaseSensitive, jei norite atskirti Indemnity nuo indemnity, pridėkite soWholeWord, kad sign nebūtų randamas signature viduje, arba sujunkite abu

Whole-word tikrinimui reikia aiškios žodžio ribos apibrėžties, ir čia taisyklę verta įvardyti tiesiai, nes ji pagal dizainą yra orientuota į ASCII. Simbolis laikomas žodžio dalimi, jei tai yra ASCII raidė, ASCII skaitmuo arba pabraukimo brūkšnys: [A-Za-z0-9_] klasė, pažįstama iš identifikatorių taisyklių. Atitikmuo laikomas whole-word tik tada, kai simboliai tiesiai prieš jį ir po jo yra ne žodžio simboliai, arba kai atitikmuo yra pačiame bloko krašte

Jei dirbate su nelotyniškais rašmenimis, šią pasekmę verta žinoti prieš išleidžiant daugiakalbę paieškos dėžutę. Kadangi kinų rašmenys, kana ir kitos ne ASCII raidės į šią klasę nepatenka, kiekviena riba šalia jų laikoma nežodine. Praktikoje tai reiškia, kad whole-word paieška CJK tekste elgiasi taip, tarsi kiekviena pozicija būtų tinkama žodžio riba, todėl ši vėliava ten faktiškai virsta poeilutės paieška. Tai dokumentuotas apribojimas, o ne klaida, ir jis atitinka elgseną, pagal kurią ši funkcija buvo modeliuota. Jei jūsų korpusas daugiausia CJK, whole-word režimas nesuteiks tokio segmentavimo, kokį duotų specializuotas skaidiklis; planuokite tai iš anksto, o ne pasikliaukite juo

Dar viena įgyvendinimo išnaša, paaiškinanti ištisą klasę subtilių nesėkmių kitur: palyginimas neatsižvelgiant į raidžių dydį naudoja UpperCase su WideString, o ne AnsiUpperCase. Ansi variantas grąžina AnsiString, kuris nesutampa su WideString, naudojamu likusioje grandinėje, o šių dviejų maišymas sukelia tipų neatitikimus ir, dar blogiau, nuostolingą raidžių suvienodinimą simboliams už aktyvaus kodų puslapio ribų. Unicode įeina, Unicode išeina, per visą kelią

Vienas puslapių diapazono parseris visai bibliotekai

Trečiasis parametras yra puslapių diapazono eilutė, tokia kaip "1,3,5-9". Joje nėra nieko savito: ją apdoroja ta pati PLParsePageRangeList, kuri naudojama PrintPages ir puslapių kopijavimo rutinose, todėl teisingai spausdinamas diapazonas čia ir ieškomas teisingai. Tuščia diapazono eilutė yra sargybinė reikšmė „visi puslapiai“, ir tokiu atveju SearchText visą sąrašą susidaro pats

Aprėptis tiesiogiai veikia kainą. Ieškant dešimties puslapių atkarpoje iš tūkstančio puslapių dokumento, blokai ištraukiami tik dešimčiai puslapių, o ne tūkstančiui, nes ciklas parenka ir apdoroja tik tuos puslapius, kuriuos įvardija diapazonas. Jei jau žinote, kad sakinys slepiasi priede, nurodykite tai diapazone ir praleiskite likusią failo dalį

Viduje ir paieška, ir enumeravimas eidami keičia pasirinktą puslapį, todėl kiekvienas jų įėjime išsaugo kviečiančiojo pasirinktą puslapį ir finally bloke jį atstato. Iškvieskite SearchText puslapio kūrimo viduryje ir grįžus kvietimui pažymėtas bus tiksliai tas pats puslapis, kurį buvote palikę. Toks išsaugojimo ir atkūrimo kontraktas yra tas dalykas, kurį pastebite tik tada, kai jo nėra, ir būtent todėl jis čia yra

Viso puslapio enumeravimas: tekstas ir vaizdai viename sąraše

Paieška atsako į klausimą „kur yra šis žodis“. Kita introspekcijos pusė yra „kas apskritai yra šiame puslapyje“, ir tam skirtas EnumPageElements. Jis grąžina vieną suvienytą sąrašą, kuriame kiekvienas elementas yra arba teksto blokas, arba įterptas vaizdas; juos atskiria Kind laukas:

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;

Teksto elementai ateina iš to paties ExtractPageTextBlocks praėjimo, todėl kiekvienas jų jau turi užpildytą savo stačiakampį, šrifto pavadinimą ir dydį. Vaizdų elementai ateina iš puslapio įterptų vaizdų sąrašo per FindImages ir GetImageID; jų nešamas ImageID yra identifikatorius, kurį paduodate į SelectImage, jei norite vaizdą nagrinėti toliau. Abi rūšys nusileidžia į vieną masyvą, todėl vienas ėjimas per puslapį pamato viską, kas jame yra

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;

Čia galioja skaičiavimo taisyklė, perimta iš likusios bibliotekos, ir jos privalu laikytis, kitaip skaitysite neinicializuotą atmintį. Grąžinama reikšmė yra visas elementų skaičius, kuris gali būti didesnis už jūsų perduotą masyvą. Funkcija užpildo tik tiek vietų, kiek telpa, ir toliau skaičiuoja likusias, lygiai taip pat, kaip veikia parašų enumeravimas. Todėl apsauga visada ta pati: ciklą ribokite mažesne iš dviejų reikšmių, grąžinto skaičiaus ir High(array), niekada aklai neiteruokite iki pat skaičiaus. Aukščiau pateiktuose pavyzdžiuose I <= High(...) tikrinama būtent dėl to. Jei grąžinta reikšmė viršija jūsų buferį, susikurkite didesnį masyvą ir kvieskite dar kartą

Jei jau naudojote žemesnio lygio bibliotekos teksto blokų kvietimus, tai yra tipizuotas, geometriją išlaikantis sluoksnis virš jų; pagrindinis ištraukimas yra tas pats, kuris aprašytas straipsnyje apie PDF teksto, vaizdų ir šriftų ištraukimą Delphi aplinkoje su PDFlibPas. O kai tikslas yra ne „kur yra šis tekstas“, o „kaip šis dokumentas suformuotas pagalbinėms technologijoms“, lygiagreti skaitymo pusės istorija yra pažymėto PDF struktūros medis, kuris rodo loginę skaitymo tvarką, o ne fizinį blokų išdėstymą

Regiono užklausos, kai jau žinote, kur žiūrėti

Kartais apskritai neturite paieškos termino; turite stačiakampį. Formos šablonas visada deda sąskaitos numerį į viršutinį dešinįjį kampą, o nuskenuotas maketas rezervuoja fiksuotą juostą lentelei. Tokiam atvejui skirtas GetTextInAreaEx. Tai ribas išlaikantis atitikmuo senesniam GetTextInArea: kur senasis kvietimas regionui grąžina plokščią eilučių sąrašą, naujasis prie kiekvieno išlaikyto bloko teksto prideda jo stačiakampį, todėl sužinote ne tik kas yra dėžėje, bet ir kurioje jos vietoje yra kiekviena eilutė

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;

Reikia aiškiai laikyti galvoje du dalykus. GetTextInAreaEx veikia su šiuo metu pasirinktu puslapiu, todėl pirmiausia kvieskite SelectPage; kitaip nei SearchText, jis nepriima diapazono. Ir blokas išlaikomas tada, kai jis kertasi su užklausos stačiakampiu, o ne tik tada, kai visiškai į jį telpa, todėl eilutė, kertanti ribą, vis tiek pateks į rezultatą. Ranka brėžiamam pasirinkimo rėmui tai paprastai ir yra tai, ko norite, bet jei jums reikia griežto pilno tilpimo, dabar jau galite patys perfiltruoti grąžintus stačiakampius

Kaip tai panaudoti

Bendra gija per visus tris kvietimus yra ta, kad geometrija jau nebėra kažkas, ką rekonstruojate po fakto. Paieškos atitikmuo žino savo puslapį ir savo dėžę. Puslapio elementas žino savo stačiakampį ir, jei tai tekstas, savo šriftą. Regiono užklausa praneša, kur krenta kiekviena eilutė. To pakanka, kad sukurtumėte tikrą find-and-highlight funkciją, paspaudimu randamą rodyklę ar išdėstymą suvokiantį ištraukiklį, nelįsdami žemiau viešo API ir ranka neperstatydami teksto ištraukimo konvejerio

Šios užklausų API pateikiamos kaip PDFlibPas Delphi PDF Library dalis kartu su visu teksto blokų ištraukimo sluoksniu, ant kurio jos pastatytos, ir likusiu Delphi bei C++Builder skaitymo pusės introspekcijos paviršiumi