Technický článek

Vyhledávání textu PDF v Delphi bezpečné pro Unicode: NFC a NFD

PDF Library for Delphi umí porovnávat text podle kanonické ekvivalence místo podle jednotky kódu, takže dotaz napsaný jako předkomponovaný znak najde obsah uložený jako základní písmeno plus kombinující značka, a naopak. Řídí to dvě volby vyhledávání: soCanonicalEquivalent zapíná normalizaci Unicode během porovnávání a soGraphemeClusters omezuje každý zásah i každý krok zástupného znaku na celé grafémové shluky

Chyba, kterou to opravuje, je jednou z nejčastěji hlášených a nejméně pochopených ve vyhledávání v dokumentech. Uživatel hledá jméno, nevidí žádné výsledky, jméno z dokumentu zkopíruje, vloží je do vyhledávacího pole a najde je. Zjevně nic není rozbité: oba řetězce vypadají identicky, tisknou se identicky a porovnají se jako nerovné, protože jeden je U+00E9 a druhý U+0065 následované U+0301

Proč se stejné slovo porovná jako nerovné?

Unicode umožňuje pro stejný abstraktní znak několik kódování. Latinská písmena s diakritikou existují jako předkomponované kódové body i jako sekvence základ plus kombinující značka. Korejské slabiky hangul existují jako předkomponované slabiky i jako rozložené jamo. Co konkrétní PDF obsahuje, závisí na tvůrci, platformě a někdy i fontu, a nic z toho není osobě provádějící hledání vidět

Důvod, proč to jednoduché skládání velikosti písmen neřeší, je strukturální, ne náhodný. Skládání velikosti písmen a skládání akcentů jsou na úrovni jednotek kódu jedna ku jedné: složený řetězec má stejnou délku jako původní, takže pozice zásahu ve složeném textu je pozicí zásahu v originále. Normalizace jedna ku jedné není. Jeden předkomponovaný znak se stane dvěma nebo třemi jednotkami kódu, rozložená sekvence se zase zhroutí na jednu, a po této transformaci už pozice neodpovídají textu, který jste extrahovali

Udržování souřadnic zásahu ukazujících na původní text

Tato část rozhoduje o tom, zda je normalizované hledání použitelné, nejen správné. Každá jednotka kódu produkovaná normalizací zaznamenává počáteční a koncovou pozici původního textu UTF-16, který ji vytvořil. Rekurzivní rozklady dědí zdrojový rozsah svého rodiče, kompozice slučují rozsahy svých vstupů, a když je nalezen zásah, knihovna prohledá interval mapování kvůli nejmenšímu počátku a největšímu konci

Výsledkem je, že MatchStart, MatchLength, kontextové řetězce i oba vstupní body pro náhradu nadále adresují původní extrahovaný text, ne normalizovaný mezistupeň. Bez tohoto mapování by vám normalizované hledání mohlo říct, že zásah existuje, ale ne spolehlivě kde, což způsobuje špatné zvýraznění a nebezpečnou redakci

Samotný normalizátor je samostatný: kompaktní tabulky pro kanonický rozklad, kompozici a kanonickou kombinující třídu z Unicode 15.1, přičemž hangul se řeší algoritmickými pravidly místo tabulkových položek. Nic se nenačítá z externího datového souboru a nevolá se žádné platformní normalizační API, takže služba Windows, démon Linuxu a sestavení FPC vydají na stejném vstupu identické výsledky

Hledání s kanonickou ekvivalencí

Volby tvoří množinu, takže kanonická ekvivalence se kombinuje se stávajícím chováním, jako je porovnání celých slov, zástupné znaky a skládání 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ázdný rozsah stránek = 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;

Normalizace je zapínaná záměrně dobrovolně. Sestavení textu NFD a jeho mapování pozic stojí práci a většina hledání v dokumentech obsahujících pouze ASCII to nikdy nepotřebuje. Když se volba použije, každý textový blok uloží do mezipaměti dvě transformované formy, jednu s odstraněnými kombinujícími značkami a jednu bez nich, takže dávka dotazů nad stejným blokem normalizuje jednou místo jednou na dotaz. Skládání velikosti písmen nadále prochází beze změny levnější cestou jedna ku jedné

Co se rozbije bez hranic grafémových shluků?

Jednotky kódu nejsou znaky a znaky nejsou to, co vnímají uživatelé. Vlaječkové emoji jsou dva kódové body regionálního indikátoru. Rodinné emoji je několik kódových bodů spojených spojkami s nulovou šířkou. Indický konjunkt je souhláska, virama a další souhláska. Písmeno se dvěma stohovanými akcenty jsou tři kódové body. Porovnání nebo řez uprostřed kteréhokoli z těchto útvarů vytvoří fragment, který se vykreslí jako balast

soGraphemeClusters omezuje oba konce každého zásahu, doslovného i zástupného, na kompletní hranice rozšířeného grafémového shluku. Segmentace implementuje rozšířená pravidla: párování CR a LF, řídicí znaky, třídy slabik hangul, Extend a SpacingMark, Prepend, sekvence ZWJ pro emoji, párování regionálních indikátorů a přerušení indických konjunktů. Hranice nikdy nevznikne uvnitř náhradní dvojice, což samo o sobě eliminuje celou třídu poškozených výsledků u jakéhokoli obsahu nad rámec základní vícejazyčné roviny

Volba také řídí spotřebu zástupných znaků, což je místo, kde by naivní implementace stále řezala nesprávně. Jednoznakový zástupný znak postupuje přesně o jeden kompletní shluk a zpětné navrácení pro řetězcový zástupný znak se pohybuje pouze mezi hranicemi shluků:

// Bez soGraphemeClusters může "?" spotřebovat polovinu shluku a
// vrátit zásah, jehož text končí visící kombinující značkou
Found := Lib.SearchText('c?té',
  [soWildcards, soCanonicalEquivalent, soGraphemeClusters], '', Hits);

// Stejné hranice chrání i náhradu, takže redakce a přepis obsahu
// nikdy nerozdělí emoji ani písmeno s akcentem
Replaced := Lib.SearchAndReplaceText('naïve', 'plain',
  [soCanonicalEquivalent, soGraphemeClusters], '1-20');

Volba nastavení pro reálnou zátěž

Tři kombinace pokrývají většinu případů. Pro interní vyhledávací pole v dokumentech dává soCanonicalEquivalent plus soDiacriticInsensitive shovívavé chování, jaké uživatelé očekávají, a porovnává jak obě formy kódování, tak akcentovaný i neakcentovaný pravopis. Pro právní nebo compliance hledání, kde má falešně pozitivní výsledek svou cenu, použijte soCanonicalEquivalent s soCaseSensitive a soWholeWord a skládání akcentů ponechte vypnuté, aby byla ekvivalence přesná a nezávislá na kódování

Pro cokoli, co dokument mění, přidejte soGraphemeClusters bez výjimky. Hledání, které vrátí mírně nesprávný rozsah, čtenáře jen zmate; náhrada nebo redakce, která použije stejný nesprávný rozsah, zapíše chybu přímo do souboru. Důsledky špatně určených rozsahů pro odstranění popisuje článek skutečná redakce a odstranění obsahu

Když záleží na propustnosti, upřednostněte dávkové vstupní body. SearchTextBatch spustí každý neprázdný dotaz, zatímco jsou textové bloky každé stránky v paměti, což se vyhne opětovné extrakci stránky na dotaz a znovu použije mezipaměť normalizace, a streamovací varianty vydávají zásahy bez bufferu o velikosti určené volajícím. Extrakční model pod tím vším popisuje článek vyhledávání textu a výčet prvků stránky

Písma, kde to není volitelné

Pro korejštinu je kanonická ekvivalence rozdílem mezi nalezením jména a jeho nenalezením, protože předkomponované slabiky i rozložené jamo jsou v reálných dokumentech obě běžné. Pro vietnamštinu dělá stohovaná diakritika formu kompozice zcela závislou na tvůrci. U indických písem rozhoduje zpracování konjunktů o tom, zda hranice zásahu dopadne na čitelné místo. Pro japonštinu a čínštinu je vyhledávací strana poměrně jednoduchá, i když strana rozvržení nikoli, jak popisuje článek svislé psaní pro japonštinu a čínštinu

Pravidlo je krátké: pokud korpus obsahuje jakýkoli jiný jazyk než angličtinu, zapněte kanonickou ekvivalenci a změřte náklady dřív, než rozhodnete, že jsou příliš vysoké. Ve většině sad dokumentů nejsou, a alternativou je vyhledávací funkce, která tiše selhává přesně u jmen, jejichž nalezení vašim uživatelům nejvíc záleží

Vyhledávání s podporou Unicode, extrakce, redakce a přepis textu sdílejí jeden motor pro Delphi, C++Builder a Free Pascal; kompletní seznam funkcí je na stránce PDF Library for Delphi