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
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
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
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