Техническа статия

Delphi PDF text search с hit координати: PDFlibPas

Извличането на текста на страница е лесната половина на проблема. Мигът, в който потребител напише дума в 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