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

Unicode-безопасно търсене на текст в PDF в Delphi: NFC и NFD

PDF Library for Delphi може да съпоставя текст по канонична еквивалентност, а не по кодова единица, така че заявка, въведена като предварително съставен знак, намира съдържание, съхранено като базова буква плюс комбиниращ знак, и обратното. Две опции за търсене го управляват: soCanonicalEquivalent активира Unicode нормализация по време на съпоставяне, а soGraphemeClusters ограничава всяко попадение и всяка стъпка на джокер до цели графемни клъстери

Грешката, която това поправя, е една от най-често докладваните и най-малко разбраните в търсенето на документи. Потребител търси име, не вижда резултати, копира името от документа, поставя го в полето за търсене и го намира. Нищо не е счупено по очевиден начин: двата низа изглеждат идентични, отпечатват се идентично, и се сравняват като неравни, защото единият е U+00E9, а другият е U+0065, последван от U+0301

Защо една и съща дума се сравнява като неравна?

Unicode позволява няколко кодирания за един и същ абстрактен знак. Латински букви с диакритика съществуват както като предварително съставени кодови точки, така и като последователности от база плюс комбиниращ знак. Хангъл сричките съществуват както като предварително съставени срички, така и като декомпозирани джамо. Кое от тях съдържа даден PDF зависи от производителя, платформата, и понякога от шрифта, а нищо от това не е видимо за човека, който извършва търсенето

Причината, поради която обикновеното сгъване на регистъра не решава това, е структурна, а не случайна. Сгъването на регистъра и сгъването на акценти са едно към едно на ниво кодова единица: сгънатият низ има същата дължина като оригинала, така че позиция на съвпадение в сгънатия текст е позиция на съвпадение в оригинала. Нормализацията не е едно към едно. Един предварително съставен знак става две или три кодови единици, декомпозирана последователност се свива обратно в една, и след тази трансформация позициите вече не съвпадат с текста, който сте извлекли

Запазване на координатите на попаденията, сочещи към оригиналния текст

Това е частта, която определя дали нормализираното търсене е използваемо, а не просто правилно. Всяка кодова единица, произведена от нормализацията, записва начална и крайна позиция на оригиналния UTF-16 текст, който я е произвел. Рекурсивните декомпозиции наследяват изходния диапазон на своя родител, композициите обединяват диапазоните на входните си данни, а когато бъде намерено съвпадение, библиотеката сканира интервала на съпоставяне за най-малкото начало и най-голямия край

Ефектът е, че MatchStart, MatchLength, контекстните низове и двете входни точки за замяна всички продължават да адресират оригиналния извлечен текст, не нормализирания междинен. Без това съпоставяне, нормализирано търсене би могло да ви каже, че попадение съществува, но не надеждно къде е било, което прави осветяването грешно, а редакцията (redaction) — опасна

Самият нормализатор е самостоятелен: компактни таблици за канонична декомпозиция, композиция и клас на канонично комбиниране от 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 никога не се нуждаят от нея. Когато опцията се използва, всеки текстов блок кешира две трансформирани форми, една с премахнати комбиниращи знаци и една без, така че партида заявки над един и същ блок нормализира веднъж, а не веднъж на заявка. Сгъването на регистъра продължава да преминава по по-евтиния път едно към едно, непроменено

Какво се чупи без граници на графемни клъстери?

Кодовите единици не са знаци, а знаците не са това, което потребителите възприемат. Емоджи флаг е две кодови точки за регионален индикатор. Емоджи семейство е няколко кодови точки, свързани с нулевоширочинни съединители. Индийска лигатура (conjunct) е съгласна, вирама и друга съгласна. Буква с два наслоени акцента е три кодови точки. Съпоставянето или разрязването по средата на кое да е от тях произвежда фрагмент, който се рендира като боклук

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