PDF Library for Delphi умеет сопоставлять текст по канонической эквивалентности, а не по кодовой единице, так что запрос, набранный как предсоставленный символ, находит содержимое, хранящееся как базовая буква плюс комбинирующий знак, и наоборот. Два параметра поиска управляют этим: soCanonicalEquivalent включает нормализацию Юникода при сопоставлении, а soGraphemeClusters ограничивает каждое совпадение и каждый шаг подстановочного знака целыми графемными кластерами
Ошибка, которую это исправляет, — одна из самых часто сообщаемых и наименее понимаемых в поиске по документам. Пользователь ищет имя, не видит результатов, копирует имя из документа, вставляет в поле поиска и находит его. Ничего явно не сломано: обе строки выглядят одинаково, печатаются одинаково, а сравниваются как неравные, потому что одна — это U+00E9, а другая — U+0065, за которым следует U+0301
Почему одно и то же слово сравнивается как неравное?
Юникод допускает несколько кодировок для одного и того же абстрактного символа. Латинские буквы с диакритикой существуют как предсоставленные кодовые точки и как последовательности базы плюс комбинирующего знака. Слоги хангыля существуют как предсоставленные слоги и как разложенные чамо. Какой вариант содержит конкретный PDF, зависит от производителя, платформы, а иногда и шрифта, и ничто из этого не видно человеку, выполняющему поиск
Причина, по которой простое приведение регистра не решает проблему, структурная, а не случайная. Приведение регистра и свёртка диакритики взаимно однозначны на уровне кодовых единиц: свёрнутая строка имеет ту же длину, что и исходная, поэтому позиция совпадения в свёрнутом тексте — это позиция совпадения в исходном. Нормализация не взаимно однозначна. Один предсоставленный символ становится двумя или тремя кодовыми единицами, разложенная последовательность схлопывается обратно в одну, и после этого преобразования позиции больше не совпадают с извлечённым вами текстом
Удержание координат совпадения указывающими на исходный текст
Именно эта часть определяет, пригоден ли нормализованный поиск к использованию, а не просто корректен. Каждая кодовая единица, порождённая нормализацией, записывает начальную и конечную позицию исходного текста UTF-16, из которого она получена. Рекурсивные разложения наследуют исходный диапазон своего родителя, композиции объединяют диапазоны своих входов, а при нахождении совпадения библиотека сканирует интервал отображения на предмет наименьшего начала и наибольшего конца
Эффект в том, что MatchStart, MatchLength, контекстные строки и обе точки входа замены продолжают адресовать исходный извлечённый текст, а не нормализованное промежуточное представление. Без этого отображения нормализованный поиск мог бы сообщить, что совпадение существует, но не надёжно указать, где именно, что делает подсветку неверной, а редактирование опасным
Сам нормализатор самодостаточен: компактные таблицы для канонического разложения, композиции и канонического класса комбинирования из Юникода 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 выполняет каждый непустой запрос, пока текстовые блоки страницы находятся в памяти, что избегает повторного извлечения страницы на каждый запрос и повторно использует кешированную нормализацию, а потоковые варианты выдают совпадения без буфера, заданного вызывающим кодом. Модель извлечения, лежащая в основе, описана в статье поиск текста и перечисление элементов страницы
Скрипты, где это не опционально
Для корейского каноническая эквивалентность — это разница между нахождением имени и его ненахождением, поскольку предсоставленные слоги и разложенные чамо одинаково распространены в реальных документах. Для вьетнамского наложенные диакритики делают форму композиции полностью зависимой от производителя. Для индийских систем письма обработка конъюнктов решает, попадёт ли граница совпадения в удобочитаемое место. Для японского и китайского сторона поиска сравнительно проста, хотя сторона раскладки — нет, как описано в статье вертикальное письмо для японского и китайского
Практическое правило короткое: если корпус содержит любой язык, кроме английского, включите каноническую эквивалентность и измерьте стоимость, прежде чем решать, что она слишком высока. В большинстве наборов документов это не так, а альтернатива — функция поиска, которая тихо отказывает как раз на тех именах, которые ваши пользователи хотят найти больше всего
Юникод-осведомлённый поиск, извлечение, редактирование и переписывание текста используют один движок для Delphi, C++Builder и Free Pascal; полный список возможностей — на странице PDF Library for Delphi