Teknisk artikkel

Unicode-sikkert PDF-tekstsøk i Delphi: NFC og NFD

PDF Library for Delphi kan matche tekst etter kanonisk ekvivalens snarere enn etter kodeenhet, slik at et søk skrevet som et forkomponert tegn finner innhold lagret som en grunnbokstav pluss et kombinerende merke, og omvendt. To søkealternativer styrer dette: soCanonicalEquivalent aktiverer Unicode-normalisering under matching, og soGraphemeClusters begrenser hvert treff og hvert jokertegn-steg til hele grafemklynger

Feilen dette retter, er en av de mest rapporterte og minst forståtte i dokumentsøk. En bruker søker etter et navn, ser ingen resultater, kopierer navnet ut av dokumentet, limer det inn i søkefeltet, og finner det. Ingenting er ødelagt på en åpenbar måte: de to strengene ser identiske ut, skrives ut identisk, og sammenlignes som ulike, fordi den ene er U+00E9 og den andre er U+0065 etterfulgt av U+0301

Hvorfor sammenlignes det samme ordet som ulikt?

Unicode tillater flere kodinger for det samme abstrakte tegnet. Latinske bokstaver med diakritiske tegn finnes som forkomponerte kodepunkter og som grunnbokstav-pluss-kombinerende-sekvenser. Hangul-stavelser finnes som forkomponerte stavelser og som dekomponerte jamo. Hvilken en PDF inneholder, avhenger av produsenten, plattformen, og noen ganger fonten, og ingenting av det er synlig for personen som søker

Grunnen til at enkel case-folding ikke løser dette, er strukturell snarere enn tilfeldig. Case-folding og aksentfolding er én-til-én på kodeenhetsnivå: den foldede strengen har samme lengde som originalen, så en treffposisjon i den foldede teksten er en treffposisjon i originalen. Normalisering er ikke én-til-én. Ett forkomponert tegn blir til to eller tre kodeenheter, en dekomponert sekvens kollapser tilbake til én, og etter den transformasjonen stemmer ikke lenger posisjoner overens med teksten du hentet ut

Holde treffkoordinater pekende på originalteksten

Dette er delen som avgjør om normalisert søk er brukbart snarere enn bare korrekt. Hver kodeenhet produsert av normalisering registrerer start- og sluttposisjonen til den opprinnelige UTF-16-teksten som produserte den. Rekursive dekomposisjoner arver kildeområdet til sin forelder, komposisjoner slår sammen områdene til sine inndata, og når et treff blir funnet, skanner biblioteket kartleggingsintervallet etter minste start og største slutt

Effekten er at MatchStart, MatchLength, kontekststrengene og begge erstatningsinngangspunktene fortsetter å adressere den opprinnelige uthentede teksten, ikke det normaliserte mellomresultatet. Uten den kartleggingen kunne et normalisert søk fortelle deg at et treff finnes, men ikke pålitelig hvor det var, noe som gjør uthevning feil og sladding farlig

Selve normalisereren er selvstendig: kompakte tabeller for kanonisk dekomposisjon, komposisjon og kanonisk kombinerende klasse fra Unicode 15.1, med Hangul håndtert av de algoritmiske reglene snarere enn av tabelloppføringer. Ingenting lastes fra en ekstern datafil, og ingen plattform-normaliserings-API kalles, så en Windows-tjeneste, en Linux-daemon og et FPC-bygg produserer alle identiske resultater på samme inndata

Søke med kanonisk ekvivalens

Alternativer er et sett, så kanonisk ekvivalens kombineres med eksisterende oppførsler som helordsmatching, jokertegn og aksent-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 sideintervall = 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 av en grunn. Å bygge NFD-teksten og dens posisjonskartlegging koster arbeid, og de fleste søk over rent ASCII-dokumenter trenger det aldri. Når alternativet brukes, bufrer hver tekstblokk to transformerte former, én med kombinerende merker fjernet og én uten, slik at en batch med spørringer over den samme blokken normaliserer én gang snarere enn én gang per spørring. Case-folding fortsetter å følge den billigere én-til-én-banen uendret

Hva bryter uten grafemklyngegrenser?

Kodeenheter er ikke tegn, og tegn er ikke det brukere oppfatter. En flagg-emoji er to regionale indikator-kodepunkter. En familie-emoji er flere kodepunkter bundet sammen av nullbredde-sammenføyere. En indisk konjunkt er en konsonant, en virama og en annen konsonant. En bokstav med to stablede aksenter er tre kodepunkter. Å matche eller kutte midt i noen av disse produserer et fragment som gjengis som søppel

soGraphemeClusters begrenser begge ender av hvert treff, bokstavelig eller jokertegn, til komplette utvidede grafemklyngegrenser. Segmenteringen implementerer de utvidede reglene: CR- og LF-paring, kontrolltegn, Hangul-stavelsesklasser, Extend og SpacingMark, Prepend, emoji ZWJ-sekvenser, regional indikator-paring og indiske konjunkt-brudd. En grense produseres aldri inni et surrogatpar, noe som alene eliminerer en hel klasse med korrupte resultater på alt innhold utenfor det grunnleggende flerspråklige planet

Alternativet styrer også jokertegn-forbruk, som er der en naiv implementasjon fremdeles ville kuttet feil. Det enkelttegns jokertegnet rykker frem nøyaktig én komplett klynge, og tilbakesporing for løpe-jokertegnet beveger seg bare mellom klyngegrenser:

// Uten soGraphemeClusters kan "?" konsumere halve en klynge og
// returnere et treff hvis tekst ender i et dinglende kombinerende merke
Found := Lib.SearchText('c?té',
  [soWildcards, soCanonicalEquivalent, soGraphemeClusters], '', Hits);

// De samme grensene beskytter erstatning, så sladding og
// innholdsomskriving deler aldri en emoji eller en aksentert bokstav
Replaced := Lib.SearchAndReplaceText('naïve', 'plain',
  [soCanonicalEquivalent, soGraphemeClusters], '1-20');

Velge alternativer for en reell arbeidsmengde

Tre kombinasjoner dekker de fleste tilfeller. For en intern dokumentsøkeboks gir soCanonicalEquivalent pluss soDiacriticInsensitive den overbærende oppførselen brukere forventer, og matcher både begge kodingsformene og både aksenterte og ikke-aksenterte stavemåter. For juridisk søk eller compliance-søk, der et falskt positivt har en kostnad, bruk soCanonicalEquivalent med soCaseSensitive og soWholeWord og la aksentfolding stå av, slik at ekvivalens er eksakt og kodings-uavhengig

For alt som endrer dokumentet, legg til soGraphemeClusters uten unntak. Et søk som returnerer et litt feil område, villeder bare en leser; en erstatning eller sladding som bruker det samme feile området, skriver feilen inn i filen. Konsekvensene av å få fjerningsområder feil er dekket i ekte sladding og fjerning av innhold

Når gjennomstrømning betyr noe, foretrekk batch-inngangspunktene. SearchTextBatch kjører hver ikke-tom spørring mens hver sides tekstblokker er resident, noe som unngår å hente ut siden på nytt per spørring og gjenbruker den bufrede normaliseringen, og strømningsvariantene sender ut treff uten en kallervalgt buffer. Uthentingsmodellen under er beskrevet i tekstsøk og oppramsing av sideelementer

Skript der dette ikke er valgfritt

For koreansk er kanonisk ekvivalens forskjellen mellom å finne et navn og ikke finne det, fordi forkomponerte stavelser og dekomponerte jamo begge er vanlige i virkelige dokumenter. For vietnamesisk gjør stablede diakritiske tegn komposisjonsformen helt produsentavhengig. For indiske skript avgjør konjunkt-håndtering om en treffgrense lander på et lesbart sted. For japansk og kinesisk er søkesiden forholdsvis enkel, selv om layoutsiden ikke er det, som beskrevet i vertikal skrift for japansk og kinesisk

Tommelfingerregelen er kort: hvis korpuset inneholder noe annet språk enn engelsk, slå på kanonisk ekvivalens og mål kostnaden før du avgjør at den er for høy. I de fleste dokumentsett er den ikke det, og alternativet er en søkefunksjon som stille feiler nettopp på navnene brukerne dine bryr seg mest om å finne

Unicode-bevisst søk, uthenting, sladding og tekstomskriving deler én motor for Delphi, C++Builder og Free Pascal; den komplette funksjonslisten finnes på PDF Library for Delphi-siden