Teknisk artikel

Unicode-sikker PDF-tekstsøgning i Delphi: NFC og NFD

PDF Library for Delphi kan matche tekst ved kanonisk ækvivalens frem for ved kode-enhed, så en forespørgsel skrevet som et forsammensat tegn finder indhold gemt som et grundbogstav plus et kombinerende mærke, og omvendt. To søgeindstillinger styrer det: soCanonicalEquivalent aktiverer Unicode-normalisering under matching, og soGraphemeClusters begrænser hvert hit og hvert wildcard-trin til hele grafemklynger

Fejlen, dette retter, er en af de mest rapporterede og mindst forståede i dokumentsøgning. En bruger søger efter et navn, ser ingen resultater, kopierer navnet ud af dokumentet, indsætter det i søgefeltet, og finder det. Intet er i stykker på en åbenlys måde: de to strenge ser identiske ud, printer identisk og sammenlignes som forskellige, fordi den ene er U+00E9, og den anden er U+0065 efterfulgt af U+0301

Hvorfor sammenlignes det samme ord som forskelligt?

Unicode tillader flere kodninger af det samme abstrakte tegn. Latinske bogstaver med diakritiske tegn findes som forsammensatte kodepunkter og som grundbogstav-plus-kombinerende sekvenser. Hangul-stavelser findes som forsammensatte stavelser og som dekomponerede jamo. Hvilken en en PDF indeholder, afhænger af producenten, platformen og somme tider skrifttypen, og intet af det er synligt for personen, der søger

Grunden til, at simpel case-folding ikke løser dette, er strukturel snarere end tilfældig. Case-folding og accentfolding er én-til-én på kode-enhed-niveau: den foldede streng har samme længde som originalen, så en matchposition i den foldede tekst er en matchposition i originalen. Normalisering er ikke én til én. Ét forsammensat tegn bliver til to eller tre kode-enheder, en dekomponeret sekvens kollapser tilbage til én, og efter den transformation stemmer positioner ikke længere overens med den tekst, du udtrak

At holde hit-koordinater pegende på den originale tekst

Dette er den del, der afgør, om normaliseret søgning er brugbar frem for blot korrekt. Hver kode-enhed produceret af normalisering registrerer start- og slutpositionen af den oprindelige UTF-16-tekst, der producerede den. Rekursive dekompositioner arver kildeintervallet fra deres forælder, kompositioner fletter intervallerne fra deres input, og når et match findes, scanner biblioteket afbildningsintervallet for den mindste start og den største slutning

Effekten er, at MatchStart, MatchLength, kontekststrengene og begge erstatningsindgangspunkter alle fortsætter med at adressere den originale udtrukne tekst, ikke den normaliserede mellemform. Uden den afbildning kunne en normaliseret søgning fortælle dig, at et hit findes, men ikke pålideligt hvor det var, hvilket gør fremhævning forkert og redigering farlig

Selve normaliseren er selvstændig: kompakte tabeller for kanonisk dekomposition, komposition og kanonisk kombinerende klasse fra Unicode 15.1, med Hangul håndteret af de algoritmiske regler frem for af tabelposter. Intet indlæses fra en ekstern datafil, og intet platformsnormaliserings-API kaldes, så en Windows-tjeneste, en Linux-dæmon og et FPC-build alle producerer identiske resultater på det samme input

Søgning med kanonisk ækvivalens

Indstillinger er et sæt, så kanonisk ækvivalens kombineres med de eksisterende adfærd såsom heltordsmatch, wildcards og diakritik-ufølsom folding:

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);                       // tomt sideinterval = hele dokumentet

    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;

Normalisering er opt-in af en grund. At bygge NFD-teksten og dens positionsafbildning koster arbejde, og de fleste søgninger over rene ASCII-dokumenter har aldrig brug for det. Når indstillingen bruges, cacher hver tekstblok to transformerede former, én med kombinerende mærker fjernet og én uden, så en batch af forespørgsler over den samme blok normaliserer én gang frem for én gang pr. forespørgsel. Case-folding fortsætter uændret ad den billigere én-til-én-sti

Hvad går i stykker uden grafemklyngegrænser?

Kode-enheder er ikke tegn, og tegn er ikke, hvad brugere opfatter. Et flag-emoji er to regionale indikator-kodepunkter. Et familie-emoji er flere kodepunkter forbundet af nulbredde-forbindere. Et indisk konjunkt er en konsonant, en virama og en anden konsonant. Et bogstav med to stablede accenter er tre kodepunkter. At matche eller skære midt i nogen af disse producerer et fragment, der renderer som skrammel

soGraphemeClusters begrænser begge ender af hvert hit, bogstaveligt eller wildcard, til komplette udvidede grafemklyngegrænser. Segmenteringen implementerer de udvidede regler: CR- og LF-parring, kontroltegn, Hangul-stavelsesklasser, Extend og SpacingMark, Prepend, emoji-ZWJ-sekvenser, regional-indikator-parring og indiske konjunkt-brud. En grænse produceres aldrig inde i et surrogatpar, hvilket alene eliminerer en hel klasse af korrupte resultater på alt indhold ud over det grundlæggende flersprogede plan

Indstillingen styrer også wildcard-forbrug, hvor en naiv implementering stadig ville skære forkert. Enkelttegns-wildcarden rykker præcis én komplet klynge frem, og backtracking for løbe-wildcarden bevæger sig kun mellem klyngegrænser:

// Uden soGraphemeClusters kan "?" forbruge halvdelen af en klynge og
// returnere et hit, hvis tekst ender i et dinglende kombinerende mærke
Found := Lib.SearchText('c?té',
  [soWildcards, soCanonicalEquivalent, soGraphemeClusters], '', Hits);

// De samme grænser beskytter erstatning, så redigering og
// indholdsomskrivning aldrig splitter et emoji eller et accentueret bogstav
Replaced := Lib.SearchAndReplaceText('naïve', 'plain',
  [soCanonicalEquivalent, soGraphemeClusters], '1-20');

Valg af indstillinger til en reel arbejdsbyrde

Tre kombinationer dækker de fleste tilfælde. Til et internt dokumentsøgefelt giver soCanonicalEquivalent plus soDiacriticInsensitive den overbærende adfærd, brugere forventer, ved at matche både kodningsformer og både accentuerede og ikke-accentuerede stavemåder. Til juridisk eller compliance-søgning, hvor en falsk positiv har en omkostning, brug soCanonicalEquivalent med soCaseSensitive og soWholeWord, og lad accentfolding være slået fra, så ækvivalens er eksakt og kodningsuafhængig

Til alt, der ændrer dokumentet, tilføj soGraphemeClusters uden undtagelse. En søgning, der returnerer et lidt forkert interval, vildleder kun en læser; en erstatning eller redigering, der bruger det samme forkerte interval, skriver fejlen ind i filen. Konsekvenserne af at få fjernelsesintervaller forkert er beskrevet i ægte redigering og indholdsfjernelse

Når gennemstrømning betyder noget, foretræk batch-indgangspunkterne. SearchTextBatch kører hver ikke-tom forespørgsel, mens hver sides tekstblokke er residente, hvilket undgår at genudtrække en side pr. forespørgsel og genbruger den cachede normalisering, og de streamende varianter udsender hits uden en kaldendes-størrelse buffer. Udtræksmodellen bagved er beskrevet i tekstsøgning og opremsning af sideelementer

Skrifter, hvor dette ikke er valgfrit

For koreansk er kanonisk ækvivalens forskellen mellem at finde et navn og ikke at finde det, fordi forsammensatte stavelser og dekomponerede jamo begge er almindelige i rigtige dokumenter. For vietnamesisk gør stablede diakritiske tegn kompositionsformen fuldstændig producentafhængig. For indiske skrifter afgør konjunkt-håndtering, om en hit-grænse lander et læsbart sted. For japansk og kinesisk er søgesiden forholdsvis simpel, selvom layoutsiden ikke er, som beskrevet i lodret skrift til japansk og kinesisk

Tommelfingerreglen er kort: hvis korpusset indeholder noget andet sprog end engelsk, slå kanonisk ækvivalens til, og mål omkostningen, før du beslutter, at den er for dyr. I de fleste dokumentsæt er den ikke, og alternativet er en søgefunktion, der stiltiende fejler på præcis de navne, dine brugere er mest interesserede i at finde

Unicode-bevidst søgning, udtræk, redigering og tekstomskrivning deler én motor til Delphi, C++Builder og Free Pascal; den komplette funktionsliste findes på siden PDF Library til Delphi