Извличането на текста на страница е лесната половина на проблема. Мигът, в който потребител напише дума в search box и очаква viewer-ът да скочи до нея и да нарисува жълта рамка около нея, имате нужда от нещо, което плоският text string не може да даде: страницата, на която стои всеки match, и rectangle-а, който той заема в PDF координатите. Конкатениран низ през страница е изгубил тази геометрия. Можете да намерите подниза, но не можете да посочите към него
PDFlibPas е нативна Object Pascal PDF библиотека за Delphi и C++Builder и от v3.78.0 отговаря точно на този въпрос. Три query API-та стоят върху съществуващия text-block extractor: SearchText обхожда page range и връща всеки hit с неговата page и axis-aligned rectangle, EnumPageElements изброява всичко на една page (и text blocks, и embedded images), а GetTextInAreaEx връща rectangle-а на всеки block в регион, вместо да ги сплесква до string list. Нито едно от тях не пипа write path-а; това са pure read-side добавки върху machinery, която библиотеката вече имаше
Защо геометрията живее в text-block списъка, а не във funnel-а
Натуралният инстинкт е да преизползвате каквото GetPageText върви вътрешно. Този път минава през преходен extraction „funnel“, който произвежда page string-а и после се самоунищожава преди call-ът да се върне. Към момента, в който държите резултата, per-block координатите ги няма. Те никога не са били ваши за задържане
Координатите обаче оцеляват в друга структура. ExtractPageTextBlocks(3) връща handle на text-block list, чиито items всеки носят осемдвоен bounding quad, font name, font size и текста на block-а. Този handle е единственото място, където геометрията се запазва след extraction, поради което всяко от новите query API-та стъпва върху него, а не върху funnel-а. Използването на block list-а означава, че search, enumeration и region queries споделят един extraction pass и една дефиниция на това къде се намира block-ът
Затова формата на SearchText следва от това ограничение. За всяка page в range-а той извлича block list-а, чете текста на всеки block с GetTextBlockText, тества го спрямо query-то и за block-овете, които съвпадат, редуцира quad-а до rectangle. Hit-ът, който връща, е малък record:
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;
Bound array-ът е X/Y interleaved, не четири ъгъла
Това е детайлът, който хваща първи. GetTextBlockBound(ListID, Index, BoundIndex) приема BoundIndex от 1 до 8 и тези осем стойности не са „ъгъл 1, ъгъл 2, ъгъл 3, ъгъл 4“ с по две полета, групирани заедно по начина, по който бихте предположили. Те са X, Y, X, Y, X, Y, X, Y: нечетните индекси са X координати, четните са Y координати, общо четири точки. Прочетете ги с грешно сдвояване и rectangle-ът ви е безсмислен
Причината да има quad, а не обикновен rectangle, е ротацията. Text block, поставен под ъгъл, има истински четириточков bounding polygon и осемте double-ове го описват вярно. За highlight-and-jump use case-а почти винаги искате изправена кутия, така че библиотеката редуцира quad-а до axis-aligned rectangle, като обхожда четирите точки за техните минимални и максимални X и Y. Завъртеният текст се свива до изправената кутия, която го огражда, а точно това е нужно на highlight overlay-а:
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;
Забележете, че rectangle-ът е в PDF user-space points с origin в долния ляв ъгъл на страницата, същата координатна система, която подавате на drawing и annotation извикванията. Това е умишлено: rectangle-ът, който получавате от search hit, е rectangle-ът, който можете да подадете директно на highlight annotation или на команда „scroll тук“, без да конвертирате нищо
Case sensitivity, whole words и къде CJK е различен
Вторият параметър е TPDFlibSearchOptions set, взет от soCaseSensitive и soWholeWord. Празният set [] е обичайният случай: case-insensitive substring search. Добавете soCaseSensitive за да направите Indemnity и indemnity различни, добавете soWholeWord за да спрете sign да съвпада вътре в signature, или комбинирайте и двете
Whole-word съвпадението има нужда от дефиниция за word boundary и тук правилото си струва да се каже ясно, защото е ASCII-центрично по дизайн. Един character се брои като част от дума, когато е ASCII буква, ASCII цифра или underscore: [A-Za-z0-9_] класът, познат от identifier правилата. Match се брои за whole-word само когато character-ите непосредствено преди и след него не word characters (или когато match-ът стои на edge на block-а)
Следствието за нелатинските scripts е нещо, което трябва да знаете, преди да пуснете многоезичен search box. Понеже Han characters, kana и други non-ASCII letters стоят извън този class, всяка boundary до тях се чете като non-word edge. На практика това означава, че whole-word search върху CJK text се държи така, сякаш всяка позиция е валиден word boundary, така че flag-ът ефективно се свежда до substring matching там. Това е документирано ограничение, не bug, и съвпада с поведението, по което е моделирана функцията. Ако corpus-ът ви е предимно CJK, whole-word режимът няма да ви даде segmentation-а, който би дал dedicated tokenizer; планирайте около това, вместо да разчитате на него
Една implementation бележка под линия, която обяснява клас subtle failures и другаде: case-insensitive comparison използва UpperCase на WideString, не AnsiUpperCase. Ansi variant-ът връща AnsiString, който не би се подравнил с WideString the rest of the path използва, а смесването на двете произвежда type mismatch-и и, още по-лошо, lossy folding за characters извън активната code page. Unicode in, Unicode out, през целия път
Една page range parser за цялата библиотека
Третият параметър е page range string като "1,3,5-9". Няма нищо custom в това как се парсва: същият PLParsePageRangeList зад PrintPages и page-copy routines го обработва и тук, така че range, който печата правилно, търси правилно. Празен range string е sentinel-ът за „всяка page“, в който случай SearchText изгражда пълния списък сам
Обхватът има значение за cost-а. Търсенето в десетстраничен slice на хилядастраничен document извлича blocks за десет страници, не за хиляда, защото loop-ът избира и извлича само страниците, които range-ът назовава. Когато вече знаете, че clause живее в appendix-а, кажете го в range-а и прескочете останалата част от файла
Вътрешно search и enumeration и двете сменят selected page-а, докато обхождат, така че всяко едно запазва selected page-а на caller-а при entry и го възстановява в finally block. Извикайте SearchText в средата на изграждане на page и selection-ът ви е точно там, където сте го оставили, когато call-ът се върне. Този save-and-restore contract е от онези неща, които забелязвате само когато липсват, и точно затова е там
Изброяване на цяла page: text и images в един list
Search отговаря на „къде е тази дума“. Другата половина на introspection-а е „какво изобщо има на тази page“ и това е EnumPageElements. Тя връща един унифициран list, където всеки element е или text block, или embedded image, разграничен от Kind field:
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;
Text elements идват от същия ExtractPageTextBlocks pass, така че всеки от тях пристига с rectangle-а си, font name-а и size-а си вече попълнени. Image elements идват от embedded image list-а на page-а чрез FindImages и GetImageID; ImageID които носят е handle-ът, който подавате на SelectImage за да инспектирате image-а по-нататък. Двата вида кацат в един array, така че един единствен walk през page вижда всичко върху нея
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;
Има counting convention, която следва останалата част от библиотеката и която трябва да спазвате, иначе ще четете uninitialized memory. Return value-то е total element count, който може да е по-голям от array-а, който сте подали. Function-ът попълва само толкова slots, колкото влизат, и продължава да брои останалите, точно както работи signature enumeration. Така че guard-ът винаги е един и същ: ограничете loop-а до по-малкото от върнатия count и High(array), никога не итерирайте сляпо до count-а. Примерите по-горе показват I <= High(...) check-а точно по тази причина. Ако return value-то надхвърля buffer-а ви, увеличете array-а и извикайте отново
Ако сте използвали по-ниско ниво text-block calls на библиотеката, това е typed, geometry-aware layer върху тях; underlying extraction-ът е същият, описан в Delphi PDF text, image, and font extraction with PDFlibPas. И когато целта не е „къде е този text“, а „как е structuren този document за assistive technology“, паралелната read-side история е tagged-PDF structure tree, което излага logical reading order, а не physical block layout-а
Регионални заявки, когато вече знаете къде да гледате
Sometimes you do not have a search term at all; you have a rectangle. A form template always puts the invoice number in the top-right corner, or a scanned layout reserves a fixed band for a table. GetTextInAreaEx служи за този случай. Тя е bounds-carrying counterpart-ът на GetTextInArea: където по-старото извикване връща плосък list от strings за регион, новото връща rectangle-а на всеки запазен block заедно с текста му, така че научавате не само какво има в кутията, но и къде вътре в нея стои всеки ред
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;
Две неща, които трябва да държите отделно. GetTextInAreaEx работи върху current selected page, така че първо извикайте SelectPage; за разлика от SearchText, той не приема range. И един block се запазва, когато intersects query rectangle-а, не само когато е изцяло contained, така че ред, който пресича boundary-то, все пак влиза. Това обикновено е това, което искате за ръчно начертана selection box, но ако ви трябва strict containment, можете сами да филтрирате върнатите rectangles, понеже вече ги имате
Пускане в работа
Общата нишка през всички три извиквания е, че геометрията вече не е нещо, което реконструирате след факта. Search hit-ът знае своята page и box. Page element-ът знае своя rectangle и, за text, font-а си. Region query-ят отчитa къде пада всеки ред. Това е достатъчно, за да построите истинска find-and-highlight функция, index за click-to-locate или layout-aware extractor, без да слизате под публичния API или да пренаписвате text-extraction pipeline-а на ръка
Тези query API-та идват като част от PDFlibPas Delphi PDF Library, редом с пълния text-block extraction layer, върху който са изградени, и останалата read-side introspection повърхност за Delphi и C++Builder