PDF Library for Delphi може зіставляти текст за канонічною еквівалентністю, а не за кодовою одиницею, тож запит, введений як попередньо складений символ, знаходить вміст, збережений як базова літера плюс комбінуючий знак, і навпаки. Два параметри пошуку керують цим: soCanonicalEquivalent вмикає нормалізацію Unicode під час зіставлення, а soGraphemeClusters обмежує кожен збіг і кожен крок шаблону цілими графемними кластерами
Помилка, яку це виправляє, — одна з найчастіше повідомлюваних і найменш зрозумілих у пошуку документів. Користувач шукає ім'я, не бачить результатів, копіює ім'я з документа, вставляє його в поле пошуку і знаходить. Ніщо не зламано очевидним способом: обидва рядки виглядають ідентично, друкуються ідентично й порівнюються як нерівні, бо один — це U+00E9, а інший — U+0065, за яким йде U+0301
Чому те саме слово порівнюється як нерівне?
Unicode дозволяє кілька кодувань того самого абстрактного символу. Латинські літери з діакритиками існують як попередньо складені кодові точки й як послідовності базова літера плюс комбінуючий знак. Складові хангиля існують як попередньо складені склади й як розкладені джамо. Яку саме форму містить PDF, залежить від виробника, платформи, а іноді й шрифту, і ніщо з цього не видно людині, що виконує пошук
Причина, чому просте зведення регістру цього не вирішує, структурна, а не випадкова. Зведення регістру та зведення акцентів — взаємно однозначні на рівні кодових одиниць: зведений рядок має ту саму довжину, що й оригінал, тож позиція збігу в зведеному тексті — це позиція збігу в оригіналі. Нормалізація не взаємно однозначна. Один попередньо складений символ стає двома чи трьома кодовими одиницями, розкладена послідовність згортається назад в одну, і після цього перетворення позиції більше не збігаються з текстом, який ви вилучили
Утримання координат збігу вказаними на оригінальний текст
Це та частина, що визначає, чи нормалізований пошук зручний у використанні, а не просто правильний. Кожна кодова одиниця, породжена нормалізацією, записує початкову й кінцеву позицію оригінального тексту UTF-16, що її породив. Рекурсивні розклади успадковують вихідний діапазон свого батька, композиції об'єднують діапазони своїх входів, і коли знайдено збіг, бібліотека сканує інтервал відображення на найменший початок і найбільший кінець
Ефект у тому, що MatchStart, MatchLength, контекстні рядки та обидві точки входу заміни продовжують адресувати оригінальний вилучений текст, а не нормалізований проміжний. Без цього відображення нормалізований пошук міг би сказати вам, що збіг існує, але не надійно де він був, що робить підсвічування неправильним, а видалення вмісту небезпечним
Сам нормалізатор самодостатній: компактні таблиці для канонічного розкладу, композиції та класу канонічного комбінування з Unicode 15.1, де хангиль обробляється алгоритмічними правилами, а не табличними записами. Нічого не завантажується з зовнішнього файлу даних, і жодне платформове API нормалізації не викликається, тож служба Windows, демон Linux і збірка FPC дають ідентичні результати на тому самому вході
Пошук з канонічною еквівалентністю
Параметри — це множина, тож канонічна еквівалентність поєднується з наявними режимами, як-от зіставлення цілих слів, шаблони й нечутливе до діакритиків зведення:
uses
PDFlibrary;
var
Lib: TPDFlib;
Hits: array of TPDFlibSearchHit;
Found, I: Integer;
begin
Lib := TPDFlib.Create;
try
Lib.LoadFromFile('contracts.pdf', '');
SetLength(Hits, 500);
Found := Lib.SearchText('Bäcker', [soCanonicalEquivalent, soWholeWord],
'', Hits); // порожній діапазон сторінок = весь документ
for I := 0 to Found - 1 do
Log(Format('page %d: "%s" at %d (%d chars)',
[Hits[I].Page, Hits[I].MatchText, Hits[I].MatchStart,
Hits[I].MatchLength]));
finally
Lib.Free;
end;
end;
Нормалізація опційна не просто так. Побудова тексту NFD та його відображення позицій коштує роботи, і більшість пошуків по документах лише з ASCII ніколи цього не потребують. Коли параметр використовується, кожен текстовий блок кешує дві перетворені форми — одну з видаленими комбінуючими знаками й одну без них, тож пакет запитів по тому самому блоку нормалізується один раз, а не для кожного запиту. Зведення регістру й далі йде дешевшим взаємно однозначним шляхом без змін
Що ламається без меж графемних кластерів?
Кодові одиниці — це не символи, а символи — це не те, що сприймають користувачі. Емодзі прапора — це дві кодові точки регіонального індикатора. Емодзі родини — це кілька кодових точок, з'єднаних сполучниками нульової ширини. Індійська лігатура-конюнкт — це приголосна, вірама та ще одна приголосна. Літера з двома накладеними акцентами — це три кодові точки. Зіставлення чи розрізання посередині будь-якого з них дає фрагмент, що рендериться як сміття
soGraphemeClusters обмежує обидва краї кожного збігу, буквального чи шаблонного, повними межами розширеного графемного кластера. Сегментація реалізує розширені правила: парування CR і LF, керуючі символи, класи складів хангиля, Extend і SpacingMark, Prepend, послідовності ZWJ емодзі, парування регіональних індикаторів і розриви індійських конюнктів. Межа ніколи не утворюється всередині сурогатної пари, що само по собі усуває цілий клас пошкоджених результатів на будь-якому вмісті поза базовою багатомовною площиною
Параметр також регулює споживання шаблону, і саме тут наївна реалізація все одно різала б неправильно. Односимвольний шаблон просувається рівно на один цілий кластер, а відкат для шаблону послідовності рухається лише між межами кластерів:
// Без soGraphemeClusters «?» може спожити половину кластера й
// повернути збіг, чий текст закінчується висячим комбінуючим знаком
Found := Lib.SearchText('c?té',
[soWildcards, soCanonicalEquivalent, soGraphemeClusters], '', Hits);
// Ті самі межі захищають заміну, тож видалення вмісту та
// переписування ніколи не розділять емодзі чи акцентовану літеру
Replaced := Lib.SearchAndReplaceText('naïve', 'plain',
[soCanonicalEquivalent, soGraphemeClusters], '1-20');
Вибір параметрів для реального навантаження
Три комбінації покривають більшість випадків. Для внутрішнього поля пошуку документів soCanonicalEquivalent плюс soDiacriticInsensitive дає поблажливу поведінку, якої очікують користувачі, зіставляючи обидві форми кодування й обидва написання — з акцентами й без них. Для юридичного чи комплаєнс-пошуку, де хибнопозитивний результат коштує дорого, використовуйте soCanonicalEquivalent з soCaseSensitive і soWholeWord і лишіть зведення акцентів вимкненим, щоб еквівалентність була точною й незалежною від кодування
Для всього, що змінює документ, додавайте soGraphemeClusters без винятків. Пошук, що повертає трохи неправильний діапазон, лише вводить читача в оману; заміна чи видалення вмісту, що використовує той самий неправильний діапазон, записує помилку у файл. Наслідки неправильних діапазонів видалення описано у статті справжнє видалення вмісту та санітизація
Коли важлива пропускна здатність, віддавайте перевагу пакетним точкам входу. SearchTextBatch виконує кожен непорожній запит, поки текстові блоки кожної сторінки резидентні, що уникає повторного вилучення сторінки на кожен запит і повторно використовує закешовану нормалізацію, а потокові варіанти видають збіги без буфера, розміром задаваного викликачем. Модель вилучення під сподом описана у статті пошук тексту та перелічення елементів сторінки
Скрипти, де це не опційно
Для корейської мови канонічна еквівалентність — це різниця між знаходженням імені й його ненаходженням, бо попередньо складені склади й розкладені джамо обидва поширені в реальних документах. Для в'єтнамської накладені діакритики роблять форму композиції повністю залежною від виробника. Для індійських скриптів обробка конюнктів вирішує, чи межа збігу потрапляє в читабельне місце. Для японської та китайської сторона пошуку порівняно проста, хоча сторона верстки — ні, як описано у статті вертикальне письмо для японської та китайської
Емпіричне правило коротке: якщо корпус містить будь-яку мову, крім англійської, увімкніть канонічну еквівалентність і виміряйте вартість перед тим, як вирішити, що вона завелика. У більшості наборів документів це не так, а альтернатива — можливість пошуку, що мовчки провалюється саме на тих іменах, які ваші користувачі найбільше хочуть знайти
Пошук з урахуванням Unicode, вилучення, видалення вмісту й переписування тексту спільно використовують один рушій для Delphi, C++Builder і Free Pascal; повний перелік можливостей на сторінці PDF Library for Delphi