Витягнути текст сторінки - це легка половина завдання. Щойно користувач вводить слово в поле пошуку і очікує, що переглядач перейде до нього та намалює навколо нього жовту рамку, вам потрібне те, чого плоский рядок тексту дати не може: сторінка, на якій лежить кожен збіг, і прямокутник, який він займає в координатах PDF. Рядок, з’єднаний з усієї сторінки, уже втратив цю геометрію. Ви можете знайти підрядок, але не можете вказати на нього
PDFlibPas є рідною PDF-бібліотекою на Object Pascal для Delphi та C++Builder, і з v3.78.0 вона дає саме таку відповідь. Три API для запитів побудовані поверх наявного екстрактора текстових блоків: SearchText проходить діапазон сторінок і повертає кожен збіг разом із його сторінкою та вирівняним по осях прямокутником, EnumPageElements перелічує все на одній сторінці (і текстові блоки, і вбудовані зображення), та GetTextInAreaEx повертає прямокутник кожного блока всередині області замість того, щоб сплющувати їх у список рядків. Жоден із них не торкається шляху запису; це суто операції читання поверх механізмів, які бібліотека вже мала
Чому геометрія живе в списку текстових блоків, а не в лійці
Природний інстинкт - повторно використати те, що GetPageText працює всередині. Цей шлях проходить через тимчасову ланку витягання "лійки", яка формує рядок сторінки, а потім звільняє себе ще до повернення виклику. До моменту, коли ви отримуєте результат, координати окремих блоків уже зникли. Вони ніколи не були вашими, щоб їх зберігати
Координати все ж зберігаються в іншій структурі. ExtractPageTextBlocks(3) повертає дескриптор списку текстових блоків, елементи якого несуть восьмочисловий граничний чотирикутник, ім’я шрифту, розмір шрифту й текст блока. Саме в цьому дескрипторі геометрія зберігається після витягання, тому кожен з нових API для запитів побудовано саме на ньому, а не на лійці. Повторне використання списку блоків означає, що пошук, перелічення та запити по області ділять один прохід витягання і одне визначення місця блока
Отже, форма SearchText випливає з цього обмеження. Для кожної сторінки в діапазоні він витягає список блоків, читає текст кожного блока через GetTextBlockText, перевіряє його за запитом і для блоків, що збігаються, зводить квад до прямокутника. Збіг, який він повертає, - це невеликий запис:
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;
Масив меж чергує X та Y, а не чотири кути
Ось деталь, яка першою дає збій. GetTextBlockBound(ListID, Index, BoundIndex) приймає BoundIndex діапазон від 1 до 8, і ці вісім значень - це не "кут 1, кут 2, кут 3, кут 4" з двома полями, згрупованими так, як можна було б подумати. Це X, Y, X, Y, X, Y, X, Yнепарні індекси - це координати X, парні індекси - це координати Y, загалом чотири точки. Прочитайте їх у неправильній парі, і ваш прямокутник стане безглуздим
Причина, чому взагалі існує квад, а не простий прямокутник, - це поворот. Текстовий блок, розташований під кутом, має справжній чотирикутник меж із чотирьох точок, і вісім чисел точно описують його. Для сценарію підсвічування та переходу ви майже завжди хочете натомість вертикальну рамку, тому бібліотека зводить квад до вирівняного по осях прямокутника, проходячи всі чотири точки в пошуку мінімальних і максимальних X та Y. Повернутий текст стискається у вертикальну рамку, яка його охоплює, саме те, що потрібно для накладення підсвічування:
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;
Зверніть увагу: прямокутник подано в пунктах користувацького простору PDF з початком у лівому нижньому куті сторінки, у тій самій системі координат, яку ви передаєте викликам малювання та анотацій. Це зроблено навмисно: прямокутник, який ви отримуєте зі збігу пошуку, - це прямокутник, який можна одразу передати анотації підсвічування або команді "перейдіть сюди" без жодного перетворення
Чутливість до регістру, цілі слова та де CJK відрізняється
Другий параметр - це TPDFlibSearchOptions набір прапорців, узятий з soCaseSensitive та soWholeWord. Порожній набір [] є найпоширенішим випадком: пошук підрядка без урахування регістру. Додайте soCaseSensitive щоб зробити Indemnity та indemnity різними, додайте soWholeWord щоб не дозволити sign збігатися всередині signature, або поєднайте обидва
Пошук за цілим словом потребує визначення того, що таке межа слова, і тут правило варто сформулювати прямо, бо воно навмисно спирається на ASCII. Символ вважається частиною слова, коли це літера ASCII, цифра ASCII або підкреслення: клас [A-Za-z0-9_] знайомий за правилами ідентифікаторів. Збіг вважається цілим словом лише тоді, коли символи безпосередньо перед ним і після нього не символи слова (або збіг стоїть на межі блока)
Наслідок для нелатинських писемностей варто врахувати ще до того, як ви випустите багатомовне поле пошуку. Оскільки ієрогліфи Хань, кана та інші не-ASCII літери не входять до цього класу, кожна межа поруч із ними читається як межа неслова. На практиці це означає, що пошук цілого слова по CJK-тексту поводиться так, ніби кожна позиція є допустимою межею слова, тож цей прапорець там фактично зводиться до пошуку підрядка. Це задокументоване обмеження, а не помилка, і воно відповідає поведінці, яку було змодельовано для цієї функції. Якщо ваш корпус переважно CJK, режим цілого слова не дасть вам сегментації, яку забезпечив би окремий токенізатор; плануйте з урахуванням цього, а не покладайтеся на нього
Одна технічна примітка, що пояснює клас тонких збоїв в інших місцях: порівняння без урахування регістру використовує UpperCase на WideString, а не AnsiUpperCase. Варіант Ansi повертає AnsiString, що не узгоджувалося б із WideString типом, який використовує решта шляху, а змішування цих двох спричиняє невідповідність типів і, що гірше, втратне згортання символів поза межами активної кодової сторінки. Unicode на вході, Unicode на виході, весь шлях до кінця
Один парсер діапазонів сторінок для всієї бібліотеки
Третій параметр — це рядок діапазону сторінок, наприклад "1,3,5-9". У способі його розбору немає нічого спеціального: той самий PLParsePageRangeList який лежить в основі PrintPages і процедур копіювання сторінок працює тут також, тож діапазон, який коректно друкується, коректно й шукається. Порожній рядок діапазону є маркером "кожна сторінка", і тоді SearchText самостійно будує повний список
Масштаб має значення для вартості. Пошук у десятисторінковому фрагменті тисячосторінкового документа витягує блоки лише для десяти сторінок, а не для тисячі, бо цикл вибирає й витягує лише ті сторінки, які названо в діапазоні. Коли ви вже знаєте, що потрібний пункт міститься в додатку, вкажіть це в діапазоні й пропустіть решту файла
Внутрішньо пошук і перерахування під час обходу змінюють вибрану сторінку, тому кожен із них на вході зберігає сторінку, вибрану викликачем, і відновлює її в finally блоці `finally`. Викличте SearchText у середині побудови сторінки, і ваша вибрана сторінка буде саме там, де ви її залишили, коли виклик завершиться. Цю угоду збереження та відновлення помічаєш лише тоді, коли її немає, саме тому вона й існує
Перерахування всієї сторінки: текст і зображення в одному списку
Пошук відповідає на запитання «де це слово». Інша половина інтроспекції — «що взагалі є на цій сторінці», і саме для цього EnumPageElements. Він повертає один уніфікований список, у якому кожен елемент є або текстовим блоком, або вбудованим зображенням, що розрізняються за 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;
Текстові елементи надходять із того самого ExtractPageTextBlocks проходу, тож кожен приходить уже із заповненими прямокутником, назвою шрифту та розміром. Елементи зображень беруться зі списку вбудованих зображень сторінки через FindImages і GetImageID; їхній ImageID який вони містять, є дескриптором, який ви передаєте до SelectImage для подальшого огляду зображення. Обидва типи потрапляють в один масив, тож один прохід по сторінці бачить усе, що на ній є
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;
Тут діє угода щодо лічильника, яка відповідає решті бібліотеки і яку потрібно дотримуватися, інакше ви прочитаєте неініціалізовану пам’ять. Повернене значення — це загальна кількість елементів, яка може бути більшою за масив, який ви передали. Функція заповнює лише стільки комірок, скільки вміщується, і далі лише продовжує рахувати, точно так само, як працює перерахування сигнатур. Тож перевірка завжди однакова: обмежуйте цикл меншим із поверненого значення та High(array), ніколи не ідіть сліпо до цього значення. Наведені вище приклади показують I <= High(...) перевірку з цієї причини. Якщо повернене значення перевищує ваш буфер, виділіть більший масив і викличте функцію ще раз
Якщо ви вже користувалися нижчорівневими викликами бібліотеки для текстових блоків, це типізований, геометрично обізнаний шар над ними; базове витягання те саме, що описано в Витяг тексту, зображень і шрифтів PDF у Delphi з PDFlibPas. А коли мета не «де знаходиться цей текст», а «як цей документ структуровано для допоміжних технологій», паралельною історією на боці читання є дерево структури tagged-PDF, яке показує логічний порядок читання, а не фізичне розташування блоків
Запити за областю, коли ви вже знаєте, де шукати
Іноді у вас взагалі немає пошукового терміна; у вас є прямокутник. Шаблон форми завжди розміщує номер рахунку у правому верхньому куті, або відскановане компонування відводить фіксовану смугу для таблиці. GetTextInAreaEx слугує для такого випадку. Це його відповідник з урахуванням меж для GetTextInArea: де старіший виклик повертає для області плоский список рядків, новий віддає прямокутник кожного збереженого блока разом із його текстом, тож ви дізнаєтесь не лише, що є в полі, а й де саме всередині нього лежить кожен рядок
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 працює на поточній вибраній сторінці, тож спершу викличте SelectPage; на відміну від SearchText, він не приймає діапазон. І блок зберігається, коли перетинає прямокутник запиту, а не лише коли повністю міститься в ньому, тож рядок, що проходить по межі, теж потрапляє до результату. Зазвичай саме цього і хочуть для рамки виділення, намальованої вручну, але якщо вам потрібне суворе вкладення, ви можете відфільтрувати повернуті прямокутники самостійно, бо тепер вони у вас є
Застосування на практиці
Спільна думка в усіх трьох викликах така: геометрію вже не треба відновлювати заднім числом. Збіг пошуку знає свою сторінку та свій блок. Елемент сторінки знає свій прямокутник і, для тексту, свій шрифт. Запит по області показує, де опиняється кожен рядок. Цього досить, щоб зібрати справжній пошук із підсвічуванням, індекс переходу до місця кліку або витягувач, що враховує макет, не опускаючись нижче публічного API й не відтворюючи вручну конвеєр витягання тексту
Ці API запитів постачаються як частина PDFlibPas Delphi PDF Library, разом із повним шаром витягання текстових блоків, на якому вони побудовані, та рештою поверхні інспектування на читання для Delphi і C++Builder