Articol tehnic

Căutare de text PDF sigură Unicode în Delphi: NFC și NFD

PDF Library for Delphi poate potrivi text prin echivalență canonică, nu prin unitate de cod, astfel încât o interogare scrisă ca un caracter precompus găsește conținut stocat ca literă de bază plus semn combinator, și invers. Două opțiuni de căutare controlează asta: soCanonicalEquivalent activează normalizarea Unicode în timpul potrivirii, iar soGraphemeClusters constrânge fiecare rezultat și fiecare pas de wildcard la clustere de grafeme întregi

Bug-ul pe care aceasta îl rezolvă este unul dintre cele mai raportate și mai puțin înțelese în căutarea de documente. Un utilizator caută un nume, nu vede rezultate, copiază numele din document, îl lipește în caseta de căutare și îl găsește. Nimic nu e stricat într-un mod evident: cele două șiruri arată identic, se tipăresc identic și se compară inegal, deoarece unul este U+00E9, iar celălalt U+0065 urmat de U+0301

De ce se compară inegal același cuvânt?

Unicode permite mai multe codificări pentru același caracter abstract. Literele latine cu diacritice există ca puncte de cod precompuse și ca secvențe de bază plus combinator. Silabele Hangul există ca silabe precompuse și ca jamo descompuse. Care dintre ele conține un PDF depinde de producător, de platformă și, uneori, de font, iar nimic din toate acestea nu este vizibil pentru persoana care face căutarea

Motivul pentru care simpla normalizare a capitalizării (case folding) nu rezolvă asta este structural, nu incidental. Normalizarea capitalizării și a diacriticelor sunt biunivoce la nivel de unitate de cod: șirul normalizat are aceeași lungime ca originalul, așa că o poziție de potrivire în textul normalizat este o poziție de potrivire în original. Normalizarea Unicode nu este biunivocă. Un caracter precompus devine două sau trei unități de cod, o secvență descompusă se restrânge înapoi la una, iar după acea transformare, pozițiile nu se mai aliniază cu textul pe care l-ai extras

Menținerea coordonatelor de rezultat îndreptate spre textul original

Aceasta este partea care determină dacă o căutare normalizată este utilizabilă, nu doar corectă. Fiecare unitate de cod produsă de normalizare înregistrează poziția de început și de sfârșit a textului UTF-16 original care a produs-o. Descompunerile recursive moștenesc intervalul sursă al părintelui lor, compozițiile îmbină intervalele intrărilor lor, iar când se găsește o potrivire, biblioteca scanează intervalul de mapare pentru cel mai mic început și cel mai mare sfârșit

Efectul este că MatchStart, MatchLength, șirurile de context și ambele puncte de intrare pentru înlocuire continuă toate să adreseze textul extras original, nu intermediarul normalizat. Fără acea mapare, o căutare normalizată ar putea spune că există un rezultat, dar nu în mod fiabil unde a fost, ceea ce face evidențierea greșită și redactarea periculoasă

Normalizatorul în sine este autonom: tabele compacte pentru descompunerea canonică, compoziția și clasa de combinare canonică din Unicode 15.1, cu Hangul gestionat prin regulile algoritmice, nu prin intrări de tabel. Nimic nu este încărcat dintr-un fișier de date extern, iar niciun API de normalizare de platformă nu este apelat, astfel încât un serviciu Windows, un daemon Linux și un build FPC produc toate rezultate identice pe aceeași intrare

Căutarea cu echivalență canonică

Opțiunile sunt un set, așa că echivalența canonică se combină cu comportamentele existente, cum ar fi potrivirea de cuvânt întreg, wildcard-uri și normalizarea insensibilă la diacritice:

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);                       // interval de pagini gol = tot documentul

    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;

Normalizarea este opțională cu bun motiv. Construirea textului NFD și a mapării sale de poziție costă efort, iar majoritatea căutărilor pe documente doar ASCII nu au nevoie niciodată de ea. Când opțiunea este folosită, fiecare bloc de text pune în cache două forme transformate, una cu semnele combinatoare eliminate și una fără, astfel încât un lot de interogări peste același bloc se normalizează o dată, nu o dată per interogare. Normalizarea capitalizării continuă să parcurgă calea biunivocă mai ieftină, neschimbată

Ce se strică fără limite de cluster de grafeme?

Unitățile de cod nu sunt caractere, iar caracterele nu sunt ceea ce percep utilizatorii. Un emoji de steag sunt două puncte de cod de indicator regional. Un emoji de familie sunt mai multe puncte de cod unite prin joinere de lățime zero. Un conjunct indic este o consoană, o virama și o altă consoană. O literă cu două accente suprapuse sunt trei puncte de cod. Potrivirea sau tăierea la mijlocul oricăreia dintre acestea produce un fragment care randează ca gunoi

soGraphemeClusters constrânge ambele capete ale fiecărui rezultat, literal sau wildcard, la limite complete de cluster de grafeme extins. Segmentarea implementează regulile extinse: împerecherea CR și LF, caracterele de control, clasele de silabe Hangul, Extend și SpacingMark, Prepend, secvențele emoji ZWJ, împerecherea de indicatori regionali și pauzele de conjuncte indice. O limită nu este niciodată produsă în interiorul unei perechi surogat, ceea ce elimină de unul singur o întreagă clasă de rezultate corupte pe orice conținut dincolo de planul multilingv de bază

Opțiunea guvernează și consumul de wildcard, unde o implementare naivă tot ar tăia incorect. Wildcard-ul de un singur caracter avansează exact un cluster complet, iar backtracking-ul pentru wildcard-ul de rulare se mută doar între limite de cluster:

// Fără soGraphemeClusters, "?" poate consuma jumătate dintr-un cluster și
// returna un rezultat al cărui text se termină cu un semn combinator suspendat
Found := Lib.SearchText('c?té',
  [soWildcards, soCanonicalEquivalent, soGraphemeClusters], '', Hits);

// Aceleași limite protejează și înlocuirea, așa că redactarea și
// rescrierea de conținut nu despart niciodată un emoji sau o literă accentuată
Replaced := Lib.SearchAndReplaceText('naïve', 'plain',
  [soCanonicalEquivalent, soGraphemeClusters], '1-20');

Alegerea opțiunilor pentru o sarcină reală

Trei combinații acoperă majoritatea cazurilor. Pentru o casetă de căutare internă în document, soCanonicalEquivalent plus soDiacriticInsensitive oferă comportamentul tolerant pe care utilizatorii îl așteaptă, potrivind atât ambele forme de codificare, cât și ortografiile cu și fără accente. Pentru căutări juridice sau de conformitate, unde un fals pozitiv are un cost, folosește soCanonicalEquivalent cu soCaseSensitive și soWholeWord și lasă normalizarea de accente dezactivată, astfel încât echivalența este exactă și independentă de codificare

Pentru orice modifică documentul, adaugă soGraphemeClusters fără excepție. O căutare care returnează un interval ușor greșit doar induce în eroare un cititor; o înlocuire sau redactare care folosește același interval greșit scrie greșeala în fișier. Consecințele stabilirii greșite a intervalelor de eliminare sunt acoperite în redactarea și eliminarea reală de conținut

Când debitul contează, preferă punctele de intrare pe loturi. SearchTextBatch rulează fiecare interogare non-goală cât timp blocurile de text ale fiecărei pagini sunt rezidente, ceea ce evită reextragerea unei pagini per interogare și reutilizează normalizarea pusă în cache, iar variantele de flux emit rezultate fără un buffer dimensionat de apelant. Modelul de extragere de dedesubt este descris în căutarea de text și enumerarea elementelor de pagină

Scripturi unde asta nu este opțional

Pentru coreeană, echivalența canonică este diferența dintre a găsi un nume și a nu-l găsi, deoarece silabele precompuse și jamo descompuse sunt ambele comune în documente reale. Pentru vietnameză, diacriticele suprapuse fac forma de compoziție complet dependentă de producător. Pentru scripturile indice, gestionarea conjunctelor decide dacă o limită de rezultat cade într-un loc lizibil. Pentru japoneză și chineză, partea de căutare este comparativ simplă, deși partea de layout nu este, așa cum este descris în scrierea verticală pentru japoneză și chineză

Regula de bază este scurtă: dacă corpusul conține orice limbă în afară de engleză, activează echivalența canonică și măsoară costul înainte de a decide că e prea scump. În majoritatea seturilor de documente nu este, iar alternativa este o funcție de căutare care eșuează tăcut exact pe numele la care utilizatorilor tăi le pasă cel mai mult să le găsească

Căutarea sensibilă la Unicode, extragerea, redactarea și rescrierea de text împart un singur motor pentru Delphi, C++Builder și Free Pascal; lista completă de funcții este pe pagina PDF Library for Delphi