Tekninen artikkeli

Unicode-turvallinen PDF-tekstihaku Delphissä: NFC ja NFD

PDF Library for Delphi pystyy täsmäämään tekstiä kanonisen vastaavuuden perusteella koodiyksikön sijaan, joten esikoostettuna merkkinä kirjoitettu kysely löytää sisällön, joka on tallennettu peruskirjaimena plus yhdistävänä merkkinä, ja päinvastoin. Kaksi hakuasetusta hallitsee tätä: soCanonicalEquivalent ottaa käyttöön Unicode-normalisoinnin täsmäytyksen aikana, ja soGraphemeClusters rajoittaa jokaisen osuman ja jokaisen jokerimerkkiaskeleen kokonaisiin grafeemiklustereihin

Virhe, jonka tämä korjaa, on yksi eniten raportoiduista ja vähiten ymmärretyistä dokumenttihaussa. Käyttäjä hakee nimeä, ei näe tuloksia, kopioi nimen dokumentista, liittää sen hakukenttään ja löytää sen. Mikään ei ole rikki ilmeisellä tavalla: kaksi merkkijonoa näyttävät identtisiltä, tulostuvat identtisesti ja vertailevat epätasa-arvoisina, koska toinen on U+00E9 ja toinen on U+0065, jota seuraa U+0301

Miksi sama sana vertailee epätasa-arvoisena?

Unicode sallii useita koodauksia samalle abstraktille merkille. Latinalaiset kirjaimet diakriittisillä merkeillä esiintyvät esikoostettuina koodipisteinä ja peruskirjain-plus-yhdistävä-sekvensseinä. Hangul-tavut esiintyvät esikoostettuina tavuina ja hajotettuina jamoina. Se, kumpaa PDF sisältää, riippuu tuottajasta, alustasta ja joskus fontista, eikä mikään tästä ole näkyvää hakua tekevälle henkilölle

Syy siihen, miksei yksinkertainen kirjainkoon taittaminen ratkaise tätä, on rakenteellinen, ei satunnainen. Kirjainkoon ja aksenttien taittaminen ovat yksi-yhteen-suhteessa koodiyksikkötasolla: taitetulla merkkijonolla on sama pituus kuin alkuperäisellä, joten osumakohta taitetussa tekstissä on osumakohta alkuperäisessä. Normalisointi ei ole yksi-yhteen. Yksi esikoostettu merkki muuttuu kahdeksi tai kolmeksi koodiyksiköksi, hajotettu sekvenssi supistuu takaisin yhdeksi, ja tuon muunnoksen jälkeen sijainnit eivät enää täsmää poimimasi tekstin kanssa

Osuman koordinaattien pitäminen osoittamassa alkuperäiseen tekstiin

Tämä on osa, joka määrittää, onko normalisoitu haku käyttökelpoinen eikä vain oikea. Jokainen normalisoinnin tuottama koodiyksikkö tallentaa sen tuottaneen alkuperäisen UTF-16-tekstin alku- ja loppusijainnin. Rekursiiviset hajotukset perivät vanhempansa lähdealueen, koosteet yhdistävät syötteidensä alueet, ja kun osuma löytyy, kirjasto skannaa kuvausvälin pienimmän alun ja suurimman lopun löytämiseksi

Vaikutus on se, että MatchStart, MatchLength, kontekstimerkkijonot ja molemmat korvaussisäänmenopisteet jatkavat kaikki osoittamista alkuperäiseen poimittuun tekstiin, ei normalisoituun välivaiheeseen. Ilman tuota kuvausta normalisoitu haku voisi kertoa, että osuma on olemassa, muttei luotettavasti, missä se oli, mikä tekee korostuksesta väärän ja mustauksesta vaarallisen

Itse normalisoija on itsenäinen: kompaktit taulut kanoniselle hajotukselle, koostamiselle ja kanoniselle yhdistämisluokalle Unicode 15.1:stä, Hangulin käsittelyn tapahtuessa algoritmisten sääntöjen mukaan taulumerkintöjen sijaan. Mitään ei ladata ulkoisesta datatiedostosta eikä mitään alustan normalisointi-API:a kutsuta, joten Windows-palvelu, Linux-daemoni ja FPC-koonnos tuottavat kaikki identtiset tulokset samalla syötteellä

Hakeminen kanonisella vastaavuudella

Asetukset ovat joukko, joten kanoninen vastaavuus yhdistyy olemassa oleviin käyttäytymisiin, kuten koko sanan täsmäytykseen, jokerimerkkeihin ja diakriittiselle merkille herkkämättömään taittamiseen:

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);                       // tyhjä sivualue = koko dokumentti

    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;

Normalisointi on syystä opt-in. NFD-tekstin ja sen sijaintikuvauksen rakentaminen maksaa työtä, ja useimmat haut pelkkiä ASCII-dokumentteja kohtaan eivät koskaan tarvitse sitä. Kun asetusta käytetään, jokainen tekstilohko välimuistittaa kaksi muunnettua muotoa, yhden yhdistävät merkit poistettuina ja yhden ilman, joten kyselyerä samaa lohkoa kohtaan normalisoituu kerran eikä kerran kyselyä kohden. Kirjainkoon taittaminen jatkaa kulkemistaan halvempaa yksi-yhteen-polkua muuttumattomana

Mikä hajoaa ilman grafeemiklusterirajoja?

Koodiyksiköt eivät ole merkkejä, eivätkä merkit ole sitä, mitä käyttäjät havaitsevat. Lippuemoji on kaksi aluetunnuskoodipistettä. Perheemoji on useita koodipisteitä, jotka on yhdistetty nollaleveysyhdistäjillä. Intialaisten kirjoitusjärjestelmien konjunktio on konsonantti, virama ja toinen konsonantti. Kirjain kahdella pinotulla aksentilla on kolme koodipistettä. Täsmäytys tai katkaisu minkä tahansa näiden keskellä tuottaa fragmentin, joka renderöityy roskana

soGraphemeClusters rajoittaa jokaisen osuman, olipa se kirjaimellinen tai jokerimerkillinen, molemmat päät täydellisiin laajennettuihin grafeemiklusterirajoihin. Segmentointi toteuttaa laajennetut säännöt: CR- ja LF-parituksen, ohjausmerkit, Hangul-tavuluokat, Extend- ja SpacingMark-merkit, Prepend-merkit, emoji-ZWJ-sekvenssit, aluetunnusten parituksen ja intialaisten konjunktioiden katkaisut. Rajaa ei koskaan tuoteta korvikeparin sisälle, mikä yksin poistaa kokonaisen luokan turmeltuneita tuloksia missä tahansa sisällössä perus-monikielisen tason ulkopuolella

Asetus hallitsee myös jokerimerkin kulutusta, mikä on kohta, jossa naiivi toteutus katkaisisi silti väärin. Yksimerkkinen jokerimerkki etenee täsmälleen yhden täydellisen klusterin verran, ja peräkkäisjokerimerkin takaisinjäljitys liikkuu vain klusterirajojen välillä:

// Ilman soGraphemeClusters-asetusta "?" voi kuluttaa puolikkaan klusterin ja
// palauttaa osuman, jonka teksti päättyy roikkuvaan yhdistävään merkkiin
Found := Lib.SearchText('c?té',
  [soWildcards, soCanonicalEquivalent, soGraphemeClusters], '', Hits);

// Samat rajat suojaavat korvausta, joten mustaus ja
// sisällön uudelleenkirjoitus eivät koskaan halkaise emojia tai aksentoitua kirjainta
Replaced := Lib.SearchAndReplaceText('naïve', 'plain',
  [soCanonicalEquivalent, soGraphemeClusters], '1-20');

Asetusten valinta todelliselle työkuormalle

Kolme yhdistelmää kattavat useimmat tapaukset. Sisäiselle dokumenttihakukentälle soCanonicalEquivalent yhdessä soDiacriticInsensitive:n kanssa antaa anteeksiantavan käyttäytymisen, jota käyttäjät odottavat, täsmäten molemmat koodausmuodot ja sekä aksentoidut että aksentoimattomat kirjoitusasut. Oikeudelliselle tai vaatimustenmukaisuushaulle, jossa väärällä positiivisella on hinta, käytä soCanonicalEquivalent-asetusta yhdessä soCaseSensitive- ja soWholeWord-asetusten kanssa ja jätä aksenttitaittaminen pois päältä, jotta vastaavuus on tarkka ja koodauksesta riippumaton

Kaikelle, mikä muokkaa dokumenttia, lisää soGraphemeClusters poikkeuksetta. Haku, joka palauttaa hieman väärän alueen, vain johtaa lukijan harhaan; korvaus tai mustaus, joka käyttää samaa väärää aluetta, kirjoittaa virheen tiedostoon. Poistoalueiden väärin saamisen seuraukset käsitellään artikkelissa todellinen mustaus ja sisällön poisto

Kun läpimenolla on merkitystä, suosi eräkäsittelyn sisäänmenopisteitä. SearchTextBatch ajaa jokaisen ei-tyhjän kyselyn samalla, kun kunkin sivun tekstilohkot ovat muistissa, mikä välttää sivun uudelleenpoiminnan kyselyä kohden ja käyttää uudelleen välimuistitettua normalisointia, ja suoratoistomuunnelmat tuottavat osumia ilman kutsujan kokoista puskuria. Taustalla oleva poimintamalli kuvataan artikkelissa tekstihaku ja sivuelementtien luettelointi

Kirjoitusjärjestelmät, joissa tämä ei ole valinnaista

Korealle kanoninen vastaavuus on ero nimen löytymisen ja löytymättä jäämisen välillä, koska sekä esikoostetut tavut että hajotetut jamot ovat yleisiä todellisissa dokumenteissa. Vietnamille pinotut diakriittiset merkit tekevät koostomuodosta täysin tuottajariippuvaisen. Intialaisille kirjoitusjärjestelmille konjunktioiden käsittely päättää, osuuko osuman raja luettavaan kohtaan. Japanille ja kiinalle hakupuoli on suhteellisen yksinkertainen, vaikka asettelupuoli ei ole, kuten kuvataan artikkelissa pystysuuntainen kirjoitus japanille ja kiinalle

Nyrkkisääntö on lyhyt: jos aineisto sisältää mitä tahansa muuta kieltä kuin englantia, ota kanoninen vastaavuus käyttöön ja mittaa kustannus ennen kuin päätät sen olevan liian kallis. Useimmissa dokumenttijoukoissa se ei ole, ja vaihtoehto on hakuominaisuus, joka epäonnistuu hiljaa juuri niissä nimissä, joiden löytämisestä käyttäjäsi välittävät eniten

Unicode-tietoinen haku, poiminta, mustaus ja tekstin uudelleenkirjoitus jakavat yhden moottorin Delphille, C++Builderille ja Free Pascalille; täydellinen ominaisuusluettelo on sivulla PDF Library for Delphi -sivulla