Wyciągnięcie tekstu strony to łatwiejsza połowa problemu. W chwili, gdy użytkownik wpisuje słowo do pola wyszukiwania i oczekuje, że viewer przeskoczy do trafienia oraz narysuje wokół niego żółtą ramkę, potrzebujesz czegoś, czego płaski string tekstowy nie potrafi dać: strony, na której leży każde dopasowanie, oraz prostokąta, jaki zajmuje ono we współrzędnych PDF. String sklejony z całej strony tę geometrię już zgubił. Możesz znaleźć podciąg, ale nie możesz go wskazać
PDFlibPas to natywna biblioteka PDF w Object Pascal dla Delphi i C++Builder, a od wersji v3.78.0 odpowiada dokładnie na to pytanie. Na istniejącym ekstraktorze bloków tekstu zbudowano trzy API zapytań: SearchText przechodzi po zakresie stron i zwraca każde trafienie wraz ze stroną oraz prostokątem wyrównanym do osi, EnumPageElements wylicza wszystko na jednej stronie, zarówno bloki tekstu, jak i osadzone obrazy, a GetTextInAreaEx raportuje prostokąt każdego bloku wewnątrz regionu zamiast spłaszczać wynik do listy stringów. Żadne z tych wywołań nie dotyka ścieżki zapisu; to czyste rozszerzenia po stronie odczytu nad mechaniką, którą biblioteka już posiadała
Dlaczego geometria żyje na liście bloków tekstu, a nie w lejku
Naturalnym odruchem jest ponowne wykorzystanie tego, czym wewnętrznie posługuje się GetPageText. Ta ścieżka przechodzi przez przejściowy „lejek” ekstrakcji, który tworzy string strony i zwalnia samego siebie jeszcze przed powrotem wywołania. Zanim dostaniesz wynik do ręki, współrzędne poszczególnych bloków znikają. Nigdy nie były Twoje do zachowania
Współrzędne przetrwają jednak w innej strukturze. ExtractPageTextBlocks(3) zwraca handle do listy bloków tekstu, której elementy niosą ośmiodoblowy bounding quad, nazwę fontu, rozmiar fontu i tekst bloku. Ten handle to jedyne miejsce, w którym po ekstrakcji zachowuje się geometria, dlatego każde nowe API zapytań zostało zbudowane na nim, a nie na lejku. Ponowne użycie listy bloków oznacza, że wyszukiwanie, enumeracja i zapytania regionalne współdzielą jeden przebieg ekstrakcji i jedną definicję tego, gdzie znajduje się blok
Stąd bierze się kształt SearchText. Dla każdej strony z zakresu funkcja ekstrahuje listę bloków, odczytuje tekst każdego bloku przez GetTextBlockText, sprawdza go względem zapytania, a dla pasujących bloków redukuje quad do prostokąta. Zwracane trafienie ma postać niewielkiego rekordu:
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;
Tablica bound jest przeplatana X/Y, a nie zapisem czterech rogów
To właśnie ten detal gryzie najpierw. GetTextBlockBound(ListID, Index, BoundIndex) przyjmuje BoundIndex od 1 do 8, a te osiem wartości nie jest zapisane jako „róg 1, róg 2, róg 3, róg 4” z dwoma polami obok siebie, jak można by intuicyjnie zgadywać. To X, Y, X, Y, X, Y, X, Y: nieparzyste indeksy to współrzędne X, parzyste to współrzędne Y, w sumie cztery punkty. Odczytaj je w złym parowaniu, a Twój prostokąt stanie się bezsensem
Powodem, dla którego w ogóle istnieje quad zamiast zwykłego prostokąta, jest obrót. Blok tekstu ustawiony pod kątem ma rzeczywisty czteropunktowy wielokąt ograniczający, a osiem double opisuje go wiernie. Dla przypadku highlight-and-jump prawie zawsze chcesz jednak zwykłe pionowe pudełko, dlatego biblioteka redukuje quad do prostokąta wyrównanego do osi, zbierając minimum i maksimum X oraz Y z czterech punktów. Obrócony tekst zwija się do pionowego pudełka, które go obejmuje, a właśnie tego potrzebuje nakładka wyróżnienia:
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;
Zwróć uwagę, że prostokąt jest podany w punktach PDF user space, z początkiem układu w lewym dolnym rogu strony, czyli w tym samym układzie współrzędnych, którego używasz przy wywołaniach rysowania i adnotacji. To celowe: prostokąt zwrócony z trafienia wyszukiwania możesz przekazać bezpośrednio do highlight annotation albo polecenia „scroll here” bez żadnej konwersji
Case sensitivity, całe słowa i miejsce, w którym CJK działa inaczej
Drugi parametr to zbiór TPDFlibSearchOptions zbudowany z soCaseSensitive oraz soWholeWord. Pusty zbiór [] to typowy przypadek: wyszukiwanie podciągu bez rozróżniania wielkości liter. Dodaj soCaseSensitive, aby Indemnity oraz indemnity traktować rozłącznie, dodaj soWholeWord, aby zatrzymać dopasowanie sign wewnątrz signature, albo połącz oba ustawienia
Dopasowanie całych słów wymaga definicji granicy słowa i tutaj warto powiedzieć wprost, że reguła jest z założenia ASCII-centric. Znak liczy się jako część słowa wtedy, gdy jest literą ASCII, cyfrą ASCII albo podkreśleniem, czyli klasą [A-Za-z0-9_] znaną z reguł identyfikatorów. Dopasowanie kwalifikuje się jako całe słowo tylko wtedy, gdy znaki bezpośrednio przed nim i po nim nie są znakami słownymi, albo dopasowanie leży na krawędzi bloku
Konsekwencja dla pism nielatynoskich jest czymś, o czym trzeba wiedzieć przed wysłaniem wielojęzycznego pola wyszukiwania do produkcji. Ponieważ znaki Han, kana i inne litery nie-ASCII wypadają poza tę klasę, każda granica obok nich jest czytana jako krawędź niesłowna. W praktyce oznacza to, że wyszukiwanie całych słów w tekście CJK zachowuje się tak, jakby każda pozycja była poprawną granicą słowa, więc flaga w praktyce degraduje się tam do zwykłego wyszukiwania podciągu. To udokumentowane ograniczenie, a nie błąd, i odpowiada zachowaniu, na którym wzorowano tę funkcję. Jeśli Twój korpus jest głównie CJK, tryb whole-word nie da segmentacji, jaką zapewniłby wyspecjalizowany tokenizer; lepiej zaplanować to z góry niż na nim polegać
Jedna uwaga implementacyjna tłumacząca pewną klasę subtelnych awarii w innych miejscach: porównanie bez rozróżniania wielkości liter używa UpperCase na WideString, a nie AnsiUpperCase. Wariant Ansi zwraca AnsiString, które nie zgadzałoby się z WideString, którego używa reszta ścieżki, a mieszanie obu prowadzi do niezgodności typów i, co gorsza, do stratnego składania znaków spoza aktywnej strony kodowej. Unicode na wejściu, Unicode na wyjściu, przez cały przebieg
Jeden parser zakresów stron dla całej biblioteki
Trzeci parametr to string zakresu stron, taki jak "1,3,5-9". Nie ma tu nic niestandardowego w sposobie parsowania: ta sama funkcja PLParsePageRangeList, która obsługuje PrintPages oraz procedury kopiowania stron, działa także tutaj, więc zakres, który poprawnie się drukuje, poprawnie się też przeszukuje. Pusty string zakresu jest sentinelem znaczącym „każda strona”, w którym to przypadku SearchText samodzielnie buduje pełną listę
Zakres ma znaczenie dla kosztu. Wyszukiwanie dziesięciostronicowego wycinka w tysiącstronicowym dokumencie ekstrahuje bloki dla dziesięciu stron, a nie dla tysiąca, bo pętla wybiera i ekstrahuje tylko strony wskazane przez zakres. Jeśli już wiesz, że szukana klauzula siedzi w aneksie, powiedz to w zakresie i omiń resztę pliku
Wewnątrz zarówno wyszukiwanie, jak i enumeracja zmieniają zaznaczoną stronę podczas iteracji, dlatego każde z nich zapisuje stronę wybraną przez wywołującego na wejściu i przywraca ją w bloku finally. Wywołaj SearchText w trakcie budowy strony, a po powrocie wywołania zaznaczenie będzie dokładnie tam, gdzie je zostawiłeś. Taki kontrakt save-and-restore zauważa się zwykle dopiero wtedy, gdy go brakuje, i właśnie dlatego tu istnieje
Enumeracja całej strony: tekst i obrazy na jednej liście
Search odpowiada na pytanie „gdzie jest to słowo”. Druga połowa introspekcji brzmi „co w ogóle jest na tej stronie” i za to odpowiada EnumPageElements. Funkcja zwraca jedną ujednoliconą listę, w której każdy element jest albo blokiem tekstu, albo osadzonym obrazem, rozróżnianym przez pole 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;
Elementy tekstowe pochodzą z tego samego przebiegu ExtractPageTextBlocks, więc każdy przychodzi już z wypełnionym prostokątem, nazwą fontu i rozmiarem. Elementy obrazów pochodzą z listy osadzonych obrazów strony przez FindImages oraz GetImageID; niesiony przez nie ImageID to handle, który przekazujesz do SelectImage, aby dalej analizować obraz. Oba rodzaje lądują w jednej tablicy, dzięki czemu pojedyncze przejście po stronie widzi wszystko, co się na niej znajduje
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;
Jest tu konwencja zliczania zgodna z resztą biblioteki i trzeba ją uszanować, bo inaczej zaczniesz czytać niezainicjalizowaną pamięć. Wartość zwracana to całkowita liczba elementów, która może być większa niż tablica, którą przekazałeś. Funkcja wypełnia tylko tyle slotów, ile się zmieści, i dalej zlicza resztę dokładnie tak samo, jak działa enumeracja podpisów. Ochrona jest więc zawsze taka sama: przytnij pętlę do mniejszej z wartości, zwróconej liczby i High(array), nigdy nie iteruj ślepo do całego count. Powyższe przykłady pokazują warunek I <= High(...) właśnie z tego powodu. Jeśli wartość zwracana przekracza Twój bufor, utwórz większą tablicę i wywołaj funkcję ponownie
Jeśli korzystałeś z niższopoziomowych wywołań bloków tekstu w tej bibliotece, to tutaj dostajesz typowaną warstwę świadomą geometrii zbudowaną ponad nimi; bazowa ekstrakcja jest tą samą, która została opisana w ekstrakcji tekstu, obrazów i fontów PDF w Delphi z PDFlibPas. A kiedy celem nie jest „gdzie jest ten tekst”, tylko „jak ten dokument jest ustrukturyzowany dla technologii wspomagających”, równoległą historią po stronie odczytu jest drzewo struktury tagged PDF, które wystawia logiczną kolejność czytania zamiast fizycznego układu bloków
Zapytania regionalne, gdy z góry wiesz, gdzie patrzeć
Czasem w ogóle nie masz hasła do wyszukania, tylko prostokąt. Szablon formularza zawsze umieszcza numer faktury w prawym górnym rogu albo zeskanowany układ rezerwuje stały pas dla tabeli. GetTextInAreaEx obsługuje właśnie ten przypadek. To odpowiednik GetTextInArea niosący bounds: tam, gdzie starsze wywołanie zwraca dla regionu płaską listę stringów, nowe oddaje prostokąt każdego zachowanego bloku obok jego tekstu, więc dowiadujesz się nie tylko co znajduje się w ramce, ale też gdzie dokładnie leży w niej każda linia
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;
Trzeba pamiętać o dwóch rzeczach. GetTextInAreaEx działa na aktualnie wybranej stronie, więc najpierw wywołaj SelectPage; w przeciwieństwie do SearchText nie przyjmuje zakresu. Blok jest zachowywany wtedy, gdy przecina się z prostokątem zapytania, a nie tylko wtedy, gdy jest w nim w pełni zawarty, więc linia przecinająca granicę nadal trafia do wyniku. To zwykle dokładnie to, czego chcesz przy ręcznie rysowanym polu zaznaczenia, ale jeśli potrzebujesz ścisłego zawarcia, możesz sam odfiltrować zwrócone prostokąty, bo teraz już je masz
Zastosowanie w praktyce
Wspólną linią wszystkich trzech wywołań jest to, że geometria nie jest już czymś odtwarzanym po fakcie. Trafienie wyszukiwania zna swoją stronę i swój prostokąt. Element strony zna własny prostokąt i, w przypadku tekstu, także font. Zapytanie regionalne raportuje, gdzie wypada każda linia. To wystarczy, by zbudować prawdziwą funkcję find-and-highlight, indeks click-to-locate albo ekstraktor świadomy układu bez schodzenia poniżej publicznego API i bez ręcznego odtwarzania całego pipeline ekstrakcji tekstu
Te API zapytań są dostarczane jako część PDFlibPas Delphi PDF Library, wraz z pełną warstwą ekstrakcji bloków tekstu, na której zostały zbudowane, oraz resztą powierzchni introspekcji po stronie odczytu dla Delphi i C++Builder