Artykuł techniczny

Bezpieczne dla Unicode wyszukiwanie tekstu PDF w Delphi: NFC i NFD

PDF Library for Delphi potrafi dopasowywać tekst przez równoważność kanoniczną zamiast przez jednostkę kodową, więc zapytanie wpisane jako znak złożony z góry znajduje treść zapisaną jako litera bazowa plus znak łączący, i odwrotnie. Kontrolują to dwie opcje wyszukiwania: soCanonicalEquivalent włącza normalizację Unicode podczas dopasowywania, a soGraphemeClusters ogranicza każde trafienie i każdy krok symbolu wieloznacznego do pełnych klastrów grafemów

Błąd, który to naprawia, jest jednym z najczęściej zgłaszanych i najmniej rozumianych w wyszukiwaniu dokumentów. Użytkownik wyszukuje nazwisko, nie widzi wyników, kopiuje nazwisko z dokumentu, wkleja je do pola wyszukiwania i znajduje je. Nic nie jest zepsute w oczywisty sposób: oba ciągi znaków wyglądają identycznie, drukują się identycznie, a przy porównaniu wychodzą różne, ponieważ jeden to U+00E9, a drugi to U+0065 z następującym U+0301

Dlaczego to samo słowo porównuje się jako różne?

Unicode dopuszcza kilka kodowań tego samego abstrakcyjnego znaku. Litery łacińskie z diakrytykami istnieją jako punkty kodowe złożone z góry oraz jako sekwencje bazy plus znaku łączącego. Sylaby hangul istnieją jako sylaby złożone z góry oraz jako rozłożone jamo. To, którą z nich zawiera dany PDF, zależy od producenta, platformy, a czasem fontu, i nic z tego nie jest widoczne dla osoby wyszukującej

PDF Library for Delphi: To samo słowo z akcentem istnieje jako jeden prekomponowany punkt kodowy NFC albo jako litera bazowa NFD plus znak łączący, renderujące się identycznie, podczas gdy zwykłe porównanie jednostek kodowych zgłasza nierówność
Zapytanie NFC i treść NFD renderują się jako identyczne glify, a mimo to porównują się różnie bajt po bajcie, a równoważność kanoniczna sprawia, że każdy z zapisów znajdzie drugi

Powód, dla którego proste zwijanie wielkości liter tego nie rozwiązuje, jest strukturalny, nie przypadkowy. Zwijanie wielkości liter i zwijanie akcentów są jeden-do-jednego na poziomie jednostki kodowej: zwinięty ciąg ma tę samą długość co oryginał, więc pozycja dopasowania w zwiniętym tekście to pozycja dopasowania w oryginale. Normalizacja nie jest jeden-do-jednego. Jeden znak złożony z góry staje się dwiema albo trzema jednostkami kodowymi, rozłożona sekwencja zwija się z powrotem do jednej, a po takiej transformacji pozycje przestają zgadzać się z wyodrębnionym tekstem

Utrzymywanie współrzędnych trafienia wskazujących na oryginalny tekst

To ta część decyduje, czy znormalizowane wyszukiwanie jest użyteczne, a nie tylko poprawne. Każda jednostka kodowa wytworzona przez normalizację rejestruje pozycję początku i końca oryginalnego tekstu UTF-16, który ją wytworzył. Rekurencyjne rozkłady dziedziczą zakres źródłowy swojego rodzica, kompozycje scalają zakresy swoich wejść, a gdy zostanie znalezione dopasowanie, biblioteka skanuje przedział mapowania w poszukiwaniu najmniejszego początku i największego końca

Efektem jest to, że MatchStart, MatchLength, ciągi kontekstu i oba punkty wejścia zastąpienia nadal odnoszą się do oryginalnego wyodrębnionego tekstu, a nie do znormalizowanej formy pośredniej. Bez tego mapowania znormalizowane wyszukiwanie mogłoby powiedzieć, że trafienie istnieje, ale nie mogłoby wiarygodnie wskazać, gdzie ono jest, co czyni podświetlanie błędnym, a redakcję niebezpieczną

Sam normalizator jest samodzielny: zwarte tabele kanonicznego rozkładu, kompozycji i kanonicznej klasy łączenia z Unicode 15.1, z hangul obsługiwanym przez reguły algorytmiczne, a nie wpisy tabelaryczne. Nic nie jest wczytywane z zewnętrznego pliku danych i żadne API normalizacji platformy nie jest wywoływane, więc usługa Windows, demon Linuksa i kompilacja FPC dają identyczne wyniki dla tego samego wejścia

Wyszukiwanie z równoważnością kanoniczną

Opcje tworzą zbiór, więc równoważność kanoniczna łączy się z istniejącymi zachowaniami, takimi jak dopasowywanie całych słów, symbole wieloznaczne i zwijanie niewrażliwe na diakrytyki:

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);                       // pusty zakres stron = cały 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;

Normalizacja jest opcją włączaną świadomie nie bez powodu. Budowanie tekstu NFD i jego mapowania pozycji kosztuje pracę, a większość wyszukiwań w dokumentach wyłącznie ASCII nigdy jej nie potrzebuje. Gdy opcja jest użyta, każdy blok tekstu buforuje dwie przekształcone formy, jedną z usuniętymi znakami łączącymi i jedną bez, więc partia zapytań nad tym samym blokiem normalizuje się raz, a nie raz na zapytanie. Zwijanie wielkości liter nadal przechodzi tańszą ścieżką jeden-do-jednego bez zmian

PDF Library for Delphi: Diagram mapowania pozycji, gdzie każda znormalizowana jednostka niesie swój zakres źródłowy, więc MatchStart i MatchLength adresują oryginalny tekst UTF-16, a nie znormalizowany pośredni
Każda przekształcona jednostka pamięta oryginalny zakres, który ją wyprodukował, a kompozycja scala zakresy swoich wejść w najmniejszy początek plus największy koniec

Co się psuje bez granic klastrów grafemów?

Jednostki kodowe to nie znaki, a znaki to nie to, co postrzegają użytkownicy. Emoji flagi to dwa punkty kodowe wskaźnika regionalnego. Emoji rodziny to kilka punktów kodowych połączonych łącznikami o zerowej szerokości. Indyjski ligatura spółgłoskowa to spółgłoska, wirama i kolejna spółgłoska. Litera z dwoma nałożonymi akcentami to trzy punkty kodowe. Dopasowanie albo cięcie w środku którejkolwiek z tych struktur daje fragment, który renderuje się jako bezsensowny ciąg znaków

soGraphemeClusters ogranicza oba końce każdego trafienia, literalnego albo wieloznacznego, do pełnych granic rozszerzonych klastrów grafemów. Segmentacja implementuje reguły rozszerzone: parowanie CR i LF, znaki sterujące, klasy sylab hangul, Extend i SpacingMark, Prepend, sekwencje ZWJ emoji, parowanie wskaźników regionalnych oraz podziały indyjskich ligatur spółgłoskowych. Granica nigdy nie jest wytwarzana wewnątrz pary surogatów, co samo w sobie eliminuje całą klasę uszkodzonych wyników dla treści wykraczającej poza podstawową płaszczyznę wielojęzyczną

Ta opcja rządzi też konsumpcją symboli wieloznacznych, czyli miejscem, w którym naiwna implementacja wciąż cięłaby niepoprawnie. Symbol wieloznaczny jednego znaku przesuwa się dokładnie o jeden pełny klaster, a wycofywanie dla symbolu ciągu przesuwa się tylko między granicami klastrów:

// Bez soGraphemeClusters "?" może skonsumować połowę klastra i
// zwrócić trafienie, którego tekst kończy się wiszącym znakiem łączącym
Found := Lib.SearchText('c?té',
  [soWildcards, soCanonicalEquivalent, soGraphemeClusters], '', Hits);

// Te same granice chronią zastąpienie, więc redakcja i
// przepisywanie treści nigdy nie dzielą emoji ani litery z akcentem
Replaced := Lib.SearchAndReplaceText('naïve', 'plain',
  [soCanonicalEquivalent, soGraphemeClusters], '1-20');

Dobór opcji dla realnego obciążenia

Trzy kombinacje pokrywają większość przypadków. Dla wewnętrznego pola wyszukiwania dokumentów soCanonicalEquivalent plus soDiacriticInsensitive daje wybaczające zachowanie, jakiego oczekują użytkownicy, dopasowując obie formy kodowania oraz pisownię z akcentami i bez nich. Dla wyszukiwania prawnego albo zgodnościowego, gdzie fałszywe trafienie kosztuje, użyj soCanonicalEquivalent razem z soCaseSensitive i soWholeWord, a zwijanie akcentów zostaw wyłączone, tak by równoważność była dokładna i niezależna od kodowania

PDF Library for Delphi: Przykłady klastrów grafemowych obejmujące pary wskaźników regionalnych emoji flag, sekwencje emoji rodziny ZWJ i litery ze skumulowanymi diakrytykami, każdy tworzący jeden nierozbijalny klaster, obok kroków wieloznacznika ograniczonych do granic klastrów pod soGraphemeClusters
Emoji flagi to dwa wskaźniki regionalne zlane w jeden klaster, emoji rodziny łączy spoiny ZWJ, a nakładane akcenty jadą na jednej literze bazowej — wieloznaczny znak zapytania musi kroczyć dokładnie jeden taki cały klaster, gdy ustawiono soGraphemeClusters

Dla wszystkiego, co modyfikuje dokument, dodaj soGraphemeClusters bez wyjątku. Wyszukiwanie, które zwraca lekko błędny zakres, tylko wprowadza czytelnika w błąd; zastąpienie albo redakcja korzystające z tego samego błędnego zakresu wpisują błąd do pliku. Konsekwencje błędnych zakresów usuwania są opisane w prawdziwej redakcji i usuwaniu treści

Gdy liczy się przepustowość, wybieraj punkty wejścia wsadowe. SearchTextBatch uruchamia każde niepuste zapytanie, gdy bloki tekstu strony są rezydentne w pamięci, co unika ponownego wyodrębniania strony przy każdym zapytaniu i ponownie wykorzystuje zbuforowaną normalizację, a warianty strumieniowe emitują trafienia bez bufora rozmiaru dostarczonego przez wywołującego. Leżący pod spodem model ekstrakcji jest opisany w wyszukiwaniu tekstu i enumeracji elementów strony

Pisma, w których to nie jest opcjonalne

Dla koreańskiego równoważność kanoniczna to różnica między znalezieniem nazwiska a jego nieznalezieniem, ponieważ sylaby złożone z góry i rozłożone jamo są jednakowo powszechne w prawdziwych dokumentach. Dla wietnamskiego nałożone diakrytyki sprawiają, że forma kompozycji jest całkowicie zależna od producenta. Dla pism indyjskich obsługa ligatur spółgłoskowych decyduje, czy granica trafienia ląduje w czytelnym miejscu. Dla japońskiego i chińskiego strona wyszukiwania jest porównawczo prosta, choć strona układu już nie, jak opisano w pisaniu pionowym dla japońskiego i chińskiego

Reguła kciuka jest krótka: jeśli korpus zawiera jakikolwiek język inny niż angielski, włącz równoważność kanoniczną i zmierz koszt, zanim uznasz, że jest zbyt wysoki. W większości zbiorów dokumentów tak nie jest, a alternatywą jest funkcja wyszukiwania, która po cichu zawodzi dokładnie na tych nazwiskach, które użytkownikom najbardziej zależy znaleźć

Wyszukiwanie świadome Unicode, ekstrakcja, redakcja i przepisywanie tekstu współdzielą jeden silnik dla Delphi, C++Builder i Free Pascal; pełna lista funkcji znajduje się na stronie PDF Library for Delphi