Extrahování textu ze stránky je ta snadnější polovina problému. Jakmile uživatel zadá slovo do vyhledávacího pole a čeká, že se prohlížeč na něj přesune a vykreslí kolem něj žlutý rámeček, potřebujete něco, co vám prostý řetězec textu nedá: stránku, na níž každý výskyt leží, a obdélník, který v souřadnicích PDF zabírá. Řetězec slepený přes celou stránku tu geometrii ztrácí. Podřetězec najdete, ale nemůžete na něj ukázat
PDFlibPas je nativní PDF knihovna v Object Pascalu pro Delphi a C++Builder a od verze v3.78.0 odpovídá právě na tuhle otázku. Na stávajícím extraktoru textových bloků stojí tři dotazovací API: SearchText prochází rozsah stránek a vrací každý zásah spolu se stránkou a osově zarovnaným obdélníkem, EnumPageElements vypíše vše na jedné stránce (textové bloky i vložené obrázky), a GetTextInAreaEx vrací obdélník každého bloku uvnitř oblasti místo toho, aby je zploštil do seznamu řetězců. Žádné z nich nesahá na zápisovou cestu; jsou to čistě čtecí doplňky nad mechanismy, které už knihovna měla
Proč geometrie zůstává v seznamu textových bloků, ne ve funnelu
Přirozený instinkt je znovu použít cokoli GetPageText běží interně. Tudy to vede přes dočasný extrakční „funnel“, který vytvoří řetězec stránky a pak se před návratem volání uvolní. Ve chvíli, kdy máte výsledek v ruce, jsou souřadnice jednotlivých bloků pryč. Nikdy nebyly vaše k uchování
Souřadnice ale přežívají v jiné struktuře. ExtractPageTextBlocks(3) vrací handle seznamu textových bloků, jehož položky nesou vždy osm dvojic ohraničujícího čtyřúhelníku, název písma, velikost písma a text bloku. Tenhle handle je jediné místo, kde geometrie po extrakci zůstává, a proto je každé z nových query API postavené právě na něm, ne na funnelu. Znovupoužití seznamu bloků znamená, že vyhledávání, výčet i dotazy na region sdílejí jeden průchod extrakcí a jednu definici toho, kde blok leží
Tvar SearchText z toho omezení vyplývá. Pro každou stránku v rozsahu vytáhne seznam bloků, přečte text každého bloku pomocí GetTextBlockText, porovná ho s dotazem a u bloků, které se shodují, zredukuje čtyřúhelník na obdélník. Zásah, který vrací, 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 souřadnic je prokládané hodnotami X/Y, ne čtyřmi rohy
Tohle je detail, na kterém se člověk splete jako první. GetTextBlockBound(ListID, Index, BoundIndex) bere BoundIndex pole od 1 do 8 a těchto osm hodnot nejsou „roh 1, roh 2, roh 3, roh 4“ se dvěma poli seskupenými dohromady, jak byste možná čekali. Jsou to X, Y, X, Y, X, Y, X, Y: liché indexy jsou souřadnice X, sudé indexy jsou souřadnice Y, celkem čtyři body. Když je spárujete špatně, obdélník nedává smysl
Důvod, proč je tu vůbec čtyřúhelník a ne prostý obdélník, je rotace. Textový blok natočený pod úhlem má skutečný čtyřbodový ohraničující mnohoúhelník a osm hodnot double jej věrně popisuje. Pro případ zvýraznění a přeskočení obvykle chcete spíš svislý rámeček, takže knihovna čtyřúhelník zredukuje na osově zarovnaný obdélník tak, že pro čtyři body vezme minimum a maximum souřadnic X a Y. Natočený text se zhroutí do svislého rámečku, který ho obepíná, a přesně to překryv zvýraznění potřebuje:
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šimněte si, že obdélník je v bodech uživatelského prostoru PDF s počátkem v levém dolním rohu stránky, tedy ve stejné souřadnicové soustavě, kterou předáváte do kreslicích a poznámkových volání. Je to záměrné: obdélník, který dostanete zpět z vyhledávacího zásahu, můžete rovnou poslat do zvýrazňovací anotace nebo do příkazu „scroll here“ bez jakékoliv konverze
Rozlišování velikosti písmen, celá slova a v čem se liší CJK
Druhý parametr je TPDFlibSearchOptions sada složená z soCaseSensitive a soWholeWordPrázdná množina [] je běžný případ: hledání podřetězce nerozlišující velikost písmen. Přidejte soCaseSensitive aby Indemnity a indemnity odlišné, přidejte soWholeWord aby sign se neshodovalo uvnitř signature, nebo obojí zkombinujte
Shoda celého slova vyžaduje definici toho, co je hranice slova, a tady stojí za to pravidlo říct přímo, protože je záměrně orientované na ASCII. Za součást slova se považuje znak, který je ASCII písmeno, ASCII číslice nebo podtržítko: třída [A-Za-z0-9_] známá z pravidel pro identifikátory. Zásah se jako celé slovo kvalifikuje jen tehdy, když znaky bezprostředně před ním i za ním jsou ne znaků slova (nebo shoda leží na okraji bloku)
Důsledek pro ne-latinské skripty je dobré znát dřív, než nasadíte vícejazyčné vyhledávací pole. Protože znaky Han, kana a další ne-ASCII písmena do této třídy nespadají, každá hranice vedle nich se čte jako okraj mimo slovo. V praxi to znamená, že vyhledávání celých slov nad CJK textem se chová, jako by každá pozice byla platnou hranicí slova, takže se tam příznak fakticky zvrhne na hledání podřetězce. To je zdokumentované omezení, ne chyba, a odpovídá chování, podle kterého byla funkce navržena. Pokud je váš korpus převážně CJK, režim celých slov vám segmentation, kterou by poskytl specializovaný tokenizer, nedá; počítejte s tím a nespoléhejte na něj
Jedna implementační poznámka pod čarou, která vysvětluje skupinu nenápadných selhání jinde: porovnání bez rozlišení velikosti písmen používá UpperCase na WideString, ne AnsiUpperCase. Varianta Ansi vrací AnsiString, což by neodpovídalo WideString tomu, co používá zbytek cesty, a míchání obou způsobuje nesoulad typů a, hůř, ztrátové skládání pro znaky mimo aktivní kódovou stránku. Unicode dovnitř, Unicode ven, konzistentně od začátku do konce
Jeden parser rozsahů stránek pro celou knihovnu
Třetí parametr je řetězec rozsahu stránek, například "1,3,5-9". Na způsobu jeho zpracování není nic vlastního: stejný PLParsePageRangeList který používá PrintPages a rutiny pro kopírování stránek ho zpracovává i zde, takže rozsah, který se správně vypíše, se také správně prohledá. Prázdný řetězec rozsahu je zástupný znak pro "každou stránku," a v takovém případě SearchText sestaví celý seznam sám
Rozsah určuje náklady. Prohledání úseku deseti stran z tisícistránkového dokumentu vytáhne bloky jen pro deset stran, ne pro tisíc, protože smyčka vybírá a extrahuje jen stránky, které rozsah jmenuje. Když už víte, že klauzule je v příloze, napište to do rozsahu a zbytek souboru přeskočte
Uvnitř search i enumerace během průchodu mění vybranou stránku, takže každý z nich si na vstupu uloží stránku vybranou volajícím a obnoví ji v finally bloku finally. Zavolejte SearchText uprostřed sestavování stránky a vaše volba bude přesně tam, kde jste ji nechali, až se volání vrátí. Tato smlouva o uložení a obnovení je ten typ věci, které si všimnete až ve chvíli, kdy chybí, a právě proto tam je
Výčet celé stránky: text a obrázky v jednom seznamu
Search odpovídá na otázku "kde je tohle slovo". Druhá polovina introspekce je "co na té stránce vlastně je," a tím je EnumPageElements. Vrací jeden sjednocený seznam, kde je každý prvek buď textový blok, nebo vložený obrázek, rozlišený pomocí Kind pole:
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ázejí ze stejného ExtractPageTextBlocks průchodu, takže každý z nich už přichází s vyplněným obdélníkem, názvem písma i velikostí. Obrazové prvky pocházejí ze seznamu vložených obrázků stránky přes FindImages a GetImageID; ImageID které nesou, je handle, který předáte do SelectImage pro další prozkoumání obrázku. Oba typy skončí v jednom poli, takže jediný průchod stránkou uvidí všechno, co na ní 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;
Tady platí konvence počítání, která odpovídá zbytku knihovny a kterou musíte dodržet, jinak budete číst neinicializovanou paměť. Návratová hodnota je total počet prvků, který může být větší než pole, které jste předali. Funkce zaplní jen tolik slotů, kolik se jich vejde, a zbytek dál počítá, přesně jako funguje výčet signatur. Ochrana je tedy vždy stejná: omezte smyčku na menší z vráceného počtu a High(array), nikdy neiterujte slepě až do počtu. Příklady výše ukazují kontrolu I <= High(...) právě z tohoto důvodu. Pokud návratová hodnota překročí váš buffer, vytvořte větší pole a zavolejte znovu
Pokud jste používali nižší úroveň volání pro textové bloky této knihovny, je to nad nimi typovaná vrstva s ohledem na geometrii; samotná extrakce je stejná jako ta popsaná v Delphi PDF text, image, and font extraction with PDFlibPas. A když cílem není "kde je tento text," ale "jak je tento dokument strukturován pro asistivní technologie," paralelní příběh na straně čtení je tagged-PDF structure tree, které odhaluje logické pořadí čtení místo fyzického rozložení bloků
Dotazy na oblast, když už víte, kam se dívat
Někdy vůbec nemáte vyhledávací výraz; máte jen obdélník. Šablona formuláře vždy umístí číslo faktury do pravého horního rohu, nebo naskenované rozvržení vyhradí pevný pruh pro tabulku. GetTextInAreaEx slouží pro tento případ. Je to protějšek s přenášenými hranicemi k GetTextInArea: zatímco starší volání vrací pro oblast plochý seznam řetězců, nové vrací u každého zachovaného bloku jeho obdélník spolu s textem, takže zjistíte nejen to, co je v rámečku, ale i kde v něm jednotlivé řádky leží
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;
Je třeba pohlídat dvě věci. GetTextInAreaEx pracuje s aktuálně vybranou stránkou, proto nejprve zavolejte SelectPage jako první; na rozdíl od SearchText, nepřijímá rozsah. A blok se zachová, když se protíná s dotazovým obdélníkem, nejen když je v něm celý obsažen, takže řádek, který leží přes hranici, projde dál. To je obvykle to, co chcete u ručně nakresleného výběrového rámečku, ale pokud potřebujete striktní vnitřní obsažení, můžete si vrácené obdélníky odfiltrovat sami, protože je už máte k dispozici
Uveďme to do praxe
Společným jmenovatelem všech tří volání je, že geometrie už není něco, co si zpětně dopočítáváte. Výsledek hledání zná svou stránku i svůj rámeček. Prvek stránky zná svůj obdélník a u textu i své písmo. Dotaz nad oblastí vrací, kde který řádek padne. To stačí k vytvoření skutečné funkce pro hledání a zvýrazňování, indexu typu klikni a najdi, nebo extraktoru s ohledem na rozvržení, aniž byste museli klesat pod veřejné API nebo ručně znovu sestavovat textovou extrakční pipeline
Tyto dotazovací API jsou součástí PDFlibPas Delphi PDF Library, spolu s kompletní vrstvou extrakce textových bloků, na níž jsou postavená, a se zbytkem rozhraní pro introspekci nad čtením v Delphi a C++Builderu