Odborný článok

Vyhľadávanie textu v PDF bezpečné pre Unicode v Delphi: NFC a NFD

PDF Library for Delphi dokáže porovnávať text podľa kanonickej ekvivalencie namiesto podľa kódovej jednotky, takže dopyt zapísaný ako predkomponovaný znak nájde obsah uložený ako základné písmeno plus kombinačná značka, a naopak. Riadia to dve možnosti vyhľadávania: soCanonicalEquivalent zapína normalizáciu Unicode počas porovnávania a soGraphemeClusters obmedzuje každý zásah aj každý krok zástupného znaku na celé grafémové zhluky

Chyba, ktorú toto rieši, patrí medzi najčastejšie hlásené a najmenej pochopené vo vyhľadávaní v dokumentoch. Používateľ hľadá meno, nevidí žiadne výsledky, skopíruje meno z dokumentu, vloží ho do vyhľadávacieho poľa a nájde ho. Nič nie je zjavne pokazené: oba reťazce vyzerajú identicky, tlačia sa identicky a pri porovnaní vyjdú ako nerovné, pretože jeden je U+00E9 a druhý je U+0065 nasledované U+0301

Prečo sa to isté slovo pri porovnaní javí ako odlišné?

Unicode umožňuje viacero kódovaní pre ten istý abstraktný znak. Latinské písmená s diakritikou existujú ako predkomponované kódové body aj ako sekvencie základ plus kombinačná značka. Slabiky Hangul existujú ako predkomponované slabiky aj ako rozložené jamo. Ktorú z nich PDF obsahuje, závisí od producenta, platformy a niekedy aj písma, a nič z toho nevidí osoba, ktorá vyhľadáva

Dôvod, prečo jednoduché skladanie veľkosti písmen tento problém nerieši, je štrukturálny, nie náhodný. Skladanie veľkosti písmen a skladanie diakritiky sú na úrovni kódových jednotiek vzťahom jedna k jednej: sklopený reťazec má rovnakú dĺžku ako originál, takže pozícia zhody v sklopenom texte je pozíciou zhody aj v origináli. Normalizácia nie je vzťahom jedna k jednej. Jeden predkomponovaný znak sa stane dvomi alebo tromi kódovými jednotkami, rozložená sekvencia sa naopak zbalí späť do jednej, a po tejto transformácii sa pozície už nezhodujú s textom, ktorý ste extrahovali

Udržanie súradníc zásahu smerujúcich na pôvodný text

Toto je časť, ktorá rozhoduje o tom, či je normalizované vyhľadávanie použiteľné, nielen správne. Každá kódová jednotka vytvorená normalizáciou si zaznamenáva počiatočnú a koncovú pozíciu pôvodného textu UTF-16, z ktorého vznikla. Rekurzívne rozklady dedia zdrojový rozsah svojho rodiča, kompozície zlučujú rozsahy svojich vstupov, a keď sa nájde zhoda, knižnica prehľadá mapovací interval a nájde najmenší začiatok a najväčší koniec

Výsledkom je, že MatchStart, MatchLength, kontextové reťazce aj oba vstupné body nahradenia naďalej adresujú pôvodný extrahovaný text, nie normalizovaný medzistupeň. Bez tohto mapovania by vám normalizované vyhľadávanie vedelo povedať, že zásah existuje, no nie spoľahlivo kde bol, čo robí zvýrazňovanie nesprávnym a redakciu nebezpečnou

Samotný normalizátor je samostatný: kompaktné tabuľky pre kanonický rozklad, kompozíciu a kanonickú kombinačnú triedu z Unicode 15.1, pričom Hangul sa spracúva algoritmickými pravidlami, nie záznamami tabuľky. Nič sa nenačítava z externého dátového súboru a nevolá sa žiadne platformové API normalizácie, takže služba Windows, démon Linux aj zostava FPC produkujú pri rovnakom vstupe identické výsledky

Vyhľadávanie s kanonickou ekvivalenciou

Možnosti sú množina, takže kanonická ekvivalencia sa kombinuje s existujúcim správaním, ako je zhoda celého slova, zástupné znaky a skladanie necitlivé na diakritiku:

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);                       // prázdny rozsah strán = celý dokument

    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;

Normalizácia je voliteľná z dobrého dôvodu. Zostavenie textu NFD a jeho mapovania pozícií stojí prácu a väčšina vyhľadávaní nad dokumentmi iba s ASCII ju nikdy nepotrebuje. Keď sa možnosť použije, každý textový blok cachuje dve transformované formy, jednu s odstránenými kombinačnými značkami a jednu bez nich, takže dávka dopytov nad tým istým blokom normalizuje raz namiesto raz na dopyt. Skladanie veľkosti písmen naďalej prechádza nezmenenou lacnejšou cestou jedna k jednej

Čo sa pokazí bez hraníc grafémových zhlukov?

Kódové jednotky nie sú znaky a znaky nie sú to, čo vnímajú používatelia. Emoji vlajky sú dva kódové body regionálneho indikátora. Rodinné emoji je niekoľko kódových bodov spojených spojkami nulovej šírky. Indický konjunkt je spoluhláska, virama a ďalšia spoluhláska. Písmeno s dvomi naskladanými prízvukmi sú tri kódové body. Zhoda alebo rezanie uprostred ktoréhokoľvek z týchto útvarov produkuje fragment, ktorý sa vykreslí ako nezmysel

soGraphemeClusters obmedzuje oba konce každého zásahu, doslovného aj zástupného, na kompletné hranice rozšíreného grafémového zhluku. Segmentácia implementuje rozšírené pravidlá: párovanie CR a LF, riadiace znaky, triedy slabík Hangul, Extend a SpacingMark, Prepend, sekvencie emoji ZWJ, párovanie regionálnych indikátorov a zlomy indických konjunktov. Hranica sa nikdy nevytvorí vo vnútri náhradného páru (surrogate pair), čo samo osebe eliminuje celú triedu poškodených výsledkov pri akomkoľvek obsahu mimo základnej viacjazyčnej roviny

Táto možnosť tiež riadi spotrebu zástupných znakov, čo je miesto, kde by naivná implementácia stále rezala nesprávne. Zástupný znak pre jeden znak postúpi presne o jeden kompletný zhluk a spätné navracanie pre reťazcový zástupný znak sa presúva iba medzi hranicami zhlukov:

// Bez soGraphemeClusters môže „?" spotrebovať polovicu zhluku a
// vrátiť zásah, ktorého text končí visiacou kombinačnou značkou
Found := Lib.SearchText('c?té',
  [soWildcards, soCanonicalEquivalent, soGraphemeClusters], '', Hits);

// Rovnaké hranice chránia aj nahradenie, takže redakcia a
// prepisovanie obsahu nikdy nerozdelia emoji ani znak s diakritikou
Replaced := Lib.SearchAndReplaceText('naïve', 'plain',
  [soCanonicalEquivalent, soGraphemeClusters], '1-20');

Výber možností pre reálnu záťaž

Väčšinu prípadov pokrývajú tri kombinácie. Pre interné vyhľadávacie pole v dokumentoch dáva soCanonicalEquivalent spolu s soDiacriticInsensitive zhovievavé správanie, aké používatelia očakávajú, a zhoduje obe kódovacie formy aj varianty s diakritikou a bez nej. Pre právne alebo compliance vyhľadávanie, kde má falošná zhoda svoju cenu, použite soCanonicalEquivalent s soCaseSensitive a soWholeWord a nechajte skladanie diakritiky vypnuté, aby bola ekvivalencia presná a nezávislá od kódovania

Pri čomkoľvek, čo mení dokument, pridajte bez výnimky soGraphemeClusters. Vyhľadávanie, ktoré vráti mierne nesprávny rozsah, iba zavádza čitateľa; nahradenie alebo redakcia, ktorá použije ten istý nesprávny rozsah, zapíše chybu priamo do súboru. Dôsledky nesprávnych rozsahov odstránenia opisuje časť skutočná redakcia a odstránenie obsahu

Keď záleží na priepustnosti, uprednostnite dávkové vstupné body. SearchTextBatch spúšťa každý neprázdny dopyt, kým sú textové bloky každej stránky rezidentné, čím sa vyhýba opätovnej extrakcii stránky pre každý dopyt a znovu využíva cachovanú normalizáciu, a streamovacie varianty generujú zásahy bez bufferu veľkosti podľa volajúceho. Extrakčný model pod povrchom opisuje časť vyhľadávanie textu a enumerácia prvkov stránky

Písma, kde toto nie je voliteľné

Pre kórejčinu je kanonická ekvivalencia rozdielom medzi nájdením mena a jeho nenájdením, pretože predkomponované slabiky aj rozložené jamo sú v reálnych dokumentoch bežné oboje. Pre vietnamčinu naskladaná diakritika robí formu kompozície úplne závislou od producenta. Pri indických písmach rozhoduje spracovanie konjunktov o tom, či hranica zásahu pristane na čitateľnom mieste. Pre japončinu a čínštinu je vyhľadávacia stránka pomerne jednoduchá, hoci stránka rozloženia nie je, ako opisuje časť zvislé písmo pre japončinu a čínštinu

Praktické pravidlo je krátke: ak korpus obsahuje akýkoľvek iný jazyk než angličtinu, zapnite kanonickú ekvivalenciu a zmerajte náklady skôr, než rozhodnete, že sú príliš vysoké. Vo väčšine súborov dokumentov nie sú, a alternatívou je funkcia vyhľadávania, ktorá ticho zlyháva presne pri menách, ktoré vaši používatelia najviac chcú nájsť

Vyhľadávanie s podporou Unicode, extrakcia, redakcia a prepisovanie textu zdieľajú jeden engine pre Delphi, C++Builder a Free Pascal; kompletný zoznam funkcií nájdete na stránke PDF Library for Delphi