Techninis straipsnis

Unicode saugi PDF teksto paieška Delphi aplinkoje: NFC ir NFD

PDF Library for Delphi gali surasti tekstą pagal kanoninį lygiavertiškumą, o ne pagal kodo vienetą, todėl užklausa, įvesta kaip iš anksto sudarytas simbolis, suranda turinį, saugomą kaip bazinę raidę plius jungiamąjį ženklą, ir atvirkščiai. Tai valdo dvi paieškos parinktys: soCanonicalEquivalent įjungia Unicode normalizavimą atitikimo metu, o soGraphemeClusters apriboja kiekvieną rezultatą ir kiekvieną pakaitos simbolio žingsnį iki pilnų grafemos klasterių

Klaida, kurią tai ištaiso, yra viena dažniausiai pranešamų ir menkiausiai suprastų dokumentų paieškoje. Vartotojas ieško vardo, nemato rezultatų, nukopijuoja vardą iš dokumento, įklijuoja į paieškos langelį ir jį randa. Niekas akivaizdžiai nesugedę: abi eilutės atrodo identiškos, atspausdinamos identiškai, bet palyginus jos nelygios, nes viena yra U+00E9, o kita – U+0065, po kurio seka U+0301

Kodėl tas pats žodis palyginamas kaip nelygus?

Unicode leidžia kelis koduotus to paties abstraktaus simbolio užrašymus. Lotyniškos raidės su diakritiniais ženklais egzistuoja ir kaip iš anksto sudaryti kodo taškai, ir kaip bazinės raidės plius jungiamojo ženklo sekos. Korėjiečių skiemenys egzistuoja ir kaip iš anksto sudaryti skiemenys, ir kaip išskaidyti jamo. Kurį iš jų turi konkretus PDF failas, priklauso nuo kūrėjo, platformos ir kartais nuo šrifto, ir nė vienas iš šių dalykų nematomas žmogui, kuris atlieka paiešką

Priežastis, kodėl paprastas registro sulyginimas to neišsprendžia, yra struktūrinė, o ne atsitiktinė. Registro ir akcentų sulyginimas yra vienas su vienu kodo vieneto lygmenyje: suvienodinta eilutė turi tą patį ilgį kaip originali, todėl atitikimo pozicija suvienodintame tekste yra atitikimo pozicija ir originale. Normalizavimas nėra vienas su vienu. Vienas iš anksto sudarytas simbolis tampa dviem ar trimis kodo vienetais, išskaidyta seka susijungia atgal į vieną, ir po šios transformacijos pozicijos nebeatitinka teksto, kurį ištraukėte

Rezultatų koordinačių išlaikymas, nukreiptų į originalų tekstą

Būtent ši dalis nulemia, ar normalizuota paieška yra naudinga, o ne tik teisinga. Kiekvienas normalizavimo sukurtas kodo vienetas įrašo originalaus UTF-16 teksto, kuris jį sukūrė, pradžios ir pabaigos poziciją. Rekursyvūs išskaidymai paveldi savo tėvinio elemento šaltinio diapazoną, sudėtiniai elementai sujungia savo įvesčių diapazonus, o radus atitikimą biblioteka nuskaito susiejimo intervalą, ieškodama mažiausios pradžios ir didžiausios pabaigos

Rezultatas tas, kad MatchStart, MatchLength, konteksto eilutės ir abu pakeitimo įėjimo taškai visi ir toliau nurodo į originalų ištrauktą tekstą, o ne į normalizuotą tarpinį variantą. Be šio susiejimo, normalizuota paieška galėtų pasakyti, kad rezultatas egzistuoja, bet nepatikimai – kur jis buvo, o tai sugadintų paryškinimą ir padarytų redagavimą (redaction) pavojingą

Pats normalizatorius yra savarankiškas: kompaktiškos lentelės kanoniniam išskaidymui, sudėčiai ir kanoninei jungimo klasei iš Unicode 15.1, o korėjiečių kalbos atveju tvarkoma algoritminėmis taisyklėmis, o ne lentelės įrašais. Niekas neįkeliama iš išorinio duomenų failo ir nekviečiamas joks platformos normalizavimo API, todėl Windows tarnyba, Linux tarnybinė programa ir FPC statinys visi duoda identiškus rezultatus tam pačiam duomenų rinkiniui

Paieška su kanoniniu lygiavertiškumu

Parinktys yra rinkinys, todėl kanoninis lygiavertiškumas derinamas su esamomis elgsenomis, tokiomis kaip viso žodžio atitikimas, pakaitos simboliai ir diakritinius ženklus ignoruojantis sulyginimas:

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);                       // tuščias puslapių diapazonas = visas dokumentas

    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;

Normalizavimas yra sąmoningai neprivalomas (opt-in) dėl priežasties. NFD teksto ir jo pozicijų susiejimo sukūrimas kainuoja darbo, o dauguma paieškų dokumentuose, turinčiuose tik ASCII simbolius, jo niekada nereikalauja. Kai parinktis naudojama, kiekvienas teksto blokas talpina dvi transformuotas formas – vieną be jungiamųjų ženklų ir vieną su jais, todėl užklausų partija tame pačiame bloke normalizuojama vieną kartą, o ne kiekvienai užklausai atskirai. Registro sulyginimas ir toliau eina tuo pačiu pigesniu vienas-su-vienu keliu, nepakitęs

Kas sugenda be grafemos klasterio ribų?

Kodo vienetai nėra simboliai, o simboliai nėra tai, ką suvokia vartotojai. Vėliavos jaustukas yra du regioninio indikatoriaus kodo taškai. Šeimos jaustukas yra keli kodo taškai, sujungti nulinio pločio jungtukais. Indų raštų junginys yra priebalsė, virama ir kita priebalsė. Raidė su dviem sudėtais akcentais yra trys kodo taškai. Atitikimas ar kirtimas bet kurio iš jų viduryje duoda fragmentą, kuris atvaizduojamas kaip šiukšlės

soGraphemeClusters apriboja abu kiekvieno rezultato, literalaus ar pakaitos simbolio, galus iki pilnų išplėstinių grafemos klasterių ribų. Segmentavimas įgyvendina išplėstas taisykles: CR ir LF poravimą, valdymo simbolius, korėjiečių skiemenų klases, Extend ir SpacingMark, Prepend, jaustukų ZWJ sekas, regioninio indikatoriaus poravimą ir Indijos raštų junginių lūžius. Riba niekada nesukuriama pakaitinės poros viduje, o vien tai pašalina visą klasę sugadintų rezultatų bet kokiam turiniui, esančiam už bazinės daugiakalbės plokštumos ribų

Parinktis taip pat valdo pakaitos simbolio suvartojimą – čia naivi realizacija vis tiek kirstų neteisingai. Vieno simbolio pakaitos simbolis pasistumia lygiai per vieną pilną klasterį, o grįžimo atgal (backtracking) paieška su pakartojimo pakaitos simboliu juda tik tarp klasterių ribų:

// Be soGraphemeClusters "?" gali suvartoti pusę klasterio ir
// grąžinti rezultatą, kurio tekstas baigiasi pakibusiu jungiamuoju ženklu
Found := Lib.SearchText('c?té',
  [soWildcards, soCanonicalEquivalent, soGraphemeClusters], '', Hits);

// Tos pačios ribos apsaugo ir pakeitimą, todėl redagavimas ir
// turinio perrašymas niekada nesuskaido jaustuko ar raidės su akcentu
Replaced := Lib.SearchAndReplaceText('naïve', 'plain',
  [soCanonicalEquivalent, soGraphemeClusters], '1-20');

Parinkčių pasirinkimas realiam darbo krūviui

Trys derinys apima daugumą atvejų. Vidinei dokumentų paieškos juostai soCanonicalEquivalent plius soDiacriticInsensitive duoda atlaidžią elgseną, kurios tikisi vartotojai, atitinkant abi koduotes bei rašybą su akcentais ir be jų. Teisinei ar atitikties paieškai, kur klaidingas teigiamas rezultatas kainuoja, naudokite soCanonicalEquivalent su soCaseSensitive ir soWholeWord ir palikite akcentų sulyginimą išjungtą, kad lygiavertiškumas būtų tikslus ir nepriklausomas nuo koduotės

Viskam, kas keičia dokumentą, be išimčių pridėkite soGraphemeClusters. Paieška, grąžinanti šiek tiek neteisingą diapazoną, tik suklaidina skaitytoją; pakeitimas ar redagavimas, naudojantis tą patį neteisingą diapazoną, įrašo klaidą į patį failą. Pasekmės, kylančios dėl neteisingų šalinimo diapazonų, aprašytos straipsnyje tikras redagavimas ir turinio šalinimas

Kai svarbi pralaida, rinkitės partinius įėjimo taškus. SearchTextBatch vykdo kiekvieną netuščią užklausą, kol kiekvieno puslapio teksto blokai yra atmintyje, taip išvengiant pakartotinio puslapio ištraukimo kiekvienai užklausai ir pakartotinai naudojant talpinamą normalizavimą, o srautiniai variantai perduoda rezultatus be kviečiančiojo dydžio buferio. Ištraukimo modelis apačioje aprašytas straipsnyje teksto paieška ir puslapio elementų išvardijimas

Raštai, kuriuose tai nėra pasirenkama

Korėjiečių kalbai kanoninis lygiavertiškumas yra skirtumas tarp vardo suradimo ir nesuradimo, nes iš anksto sudaryti skiemenys ir išskaidytas jamo abu dažni realiuose dokumentuose. Vietnamiečių kalbai sudėti diakritiniai ženklai daro sudėties formą visiškai priklausomą nuo kūrėjo. Indų raštams junginių apdorojimas nusprendžia, ar rezultato riba atsidurs skaitomoje vietoje. Japonų ir kinų kalboms paieškos pusė santykinai paprasta, nors maketo pusė – ne, kaip aprašyta straipsnyje vertikalus rašymas japonų ir kinų kalboms

Bendra taisyklė trumpa: jei duomenų rinkinyje yra bet kokia kalba, ne tik anglų, įjunkite kanoninį lygiavertiškumą ir įvertinkite kainą prieš nuspręsdami, kad ji per didelė. Daugumoje dokumentų rinkinių ji nėra, o alternatyva – paieškos funkcija, kuri tyliai neveikia būtent su tais vardais, kuriuos jūsų vartotojams svarbiausia rasti

Unicode suvokianti paieška, ištraukimas, redagavimas ir teksto perrašymas dalijasi vienu varikliu Delphi, C++Builder ir Free Pascal aplinkoms; pilnas funkcijų sąrašas pateikiamas PDF Library for Delphi puslapyje