Teknisk artikel

Unicode-säker PDF-textsökning i Delphi: NFC och NFD

PDF Library for Delphi kan matcha text genom kanonisk ekvivalens snarare än per kodenhet, så att en fråga skriven som ett förkomponerat tecken hittar innehåll lagrat som en basbokstav plus ett kombinerande märke, och tvärtom. Två sökalternativ styr det: soCanonicalEquivalent aktiverar Unicode-normalisering under matchning, och soGraphemeClusters begränsar varje träff och varje jokerteckensteg till hela grafemkluster

Buggen detta åtgärdar är en av de mest rapporterade och minst förstådda inom dokumentsökning. En användare söker efter ett namn, ser inga resultat, kopierar namnet ur dokumentet, klistrar in det i sökrutan, och hittar det. Inget är trasigt på något uppenbart sätt: de två strängarna ser identiska ut, skrivs ut identiskt, och jämförs som olika, eftersom den ena är U+00E9 och den andra är U+0065 följt av U+0301

Varför jämförs samma ord som olika?

Unicode tillåter flera kodningar för samma abstrakta tecken. Latinska bokstäver med diakritiska tecken finns som förkomponerade kodpunkter och som bas-plus-kombinerande-sekvenser. Hangul-stavelser finns som förkomponerade stavelser och som uppdelade jamo. Vilken en PDF innehåller beror på producenten, plattformen, och ibland typsnittet, och inget av det är synligt för personen som söker

Skälet att enkel skiftlägesutjämning inte löser detta är strukturellt snarare än tillfälligt. Skiftläges- och accentutjämning är en-till-en på kodenhetsnivå: den utjämnade strängen har samma längd som originalet, så en träffposition i den utjämnade texten är en träffposition i originalet. Normalisering är inte en till en. Ett förkomponerat tecken blir två eller tre kodenheter, en uppdelad sekvens kollapsar tillbaka till en, och efter den transformationen ligger positionerna inte längre i linje med texten du extraherade

Att hålla träffkoordinater pekande på originaltexten

Detta är delen som avgör om normaliserad sökning är användbar snarare än bara korrekt. Varje kodenhet producerad av normalisering registrerar start- och slutpositionen för den ursprungliga UTF-16-text som producerade den. Rekursiva uppdelningar ärver käll­intervallet från sin förälder, sammansättningar slår ihop intervallen för sina indata, och när en träff hittas skannar biblioteket mappningsintervallet efter den minsta starten och den största slutpunkten

Effekten är att MatchStart, MatchLength, kontextsträngarna och båda ersättningsingångspunkterna fortsätter att adressera den ursprungliga extraherade texten, inte det normaliserade mellansteget. Utan den mappningen kunde en normaliserad sökning berätta att en träff finns men inte tillförlitligt var den var, vilket gör markering fel och redigering farlig

Normaliseraren själv är självständig: kompakta tabeller för kanonisk uppdelning, sammansättning och kanonisk kombinationsklass från Unicode 15.1, med Hangul hanterat av de algoritmiska reglerna snarare än av tabellposter. Inget laddas från en extern datafil och inget plattformsnormaliserings-API anropas, så en Windows-tjänst, en Linux-demon och ett FPC-bygge producerar alla identiska resultat på samma indata

Att söka med kanonisk ekvivalens

Alternativen är en uppsättning, så kanonisk ekvivalens kombineras med de befintliga beteendena som helordsmatchning, jokertecken och accent-okänslig utjämning:

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 sidintervall = hela 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 är opt-in av ett skäl. Att bygga NFD-texten och dess positionsmappning kostar arbete, och de flesta sökningar över rent ASCII-dokument behöver den aldrig. När alternativet används cachar varje textblock två transformerade former, en med kombinerande märken borttagna och en utan, så att en batch av frågor över samma block normaliseras en gång snarare än en gång per fråga. Skiftlägesutjämning fortsätter att färdas den billigare en-till-en-vägen oförändrad

Vad går sönder utan grafemklustergränser?

Kodenheter är inte tecken, och tecken är inte vad användare uppfattar. En flaggemoji är två regionala indikator-kodpunkter. En familjeemoji är flera kodpunkter sammanfogade med nollbreddsfogare. En indisk konjunkt är en konsonant, en virama och en till konsonant. En bokstav med två staplade accenter är tre kodpunkter. Att matcha eller kapa mitt i något av dessa producerar ett fragment som renderas som skräp

soGraphemeClusters begränsar båda ändarna av varje träff, literal eller jokertecken, till kompletta utökade grafemklustergränser. Segmenteringen implementerar de utökade reglerna: CR- och LF-parning, kontrolltecken, Hangul-stavelseklasser, Extend och SpacingMark, Prepend, emoji-ZWJ-sekvenser, regional indikator-parning och indiska konjunktbrytningar. En gräns produceras aldrig inuti ett surrogatpar, vilket ensamt eliminerar en hel klass av korrupta resultat på allt innehåll bortom det grundläggande flerspråkiga planet

Alternativet styr också jokerteckenkonsumtion, vilket är där en naiv implementation fortfarande skulle kapa felaktigt. Enteckensjokertecknet avancerar exakt ett komplett kluster, och backtracking för löpjokertecknet rör sig bara mellan klustergränser:

// Utan soGraphemeClusters kan "?" konsumera halva ett kluster och
// returnera en träff vars text slutar i ett löst hängande kombinerande märke
Found := Lib.SearchText('c?té',
  [soWildcards, soCanonicalEquivalent, soGraphemeClusters], '', Hits);

// Samma gränser skyddar ersättning, så redigering och
// innehållsomskrivning delar aldrig en emoji eller en accenterad bokstav
Replaced := Lib.SearchAndReplaceText('naïve', 'plain',
  [soCanonicalEquivalent, soGraphemeClusters], '1-20');

Att välja alternativ för en verklig arbetsbelastning

Tre kombinationer täcker de flesta fall. För en intern dokumentsökruta ger soCanonicalEquivalent plus soDiacriticInsensitive det tillmötesgående beteende användare förväntar sig, matchande både kodningsformer och både accenterade och oaccenterade stavningar. För juridisk eller regelefterlevnadssökning, där ett falskt positivt har en kostnad, använd soCanonicalEquivalent med soCaseSensitive och soWholeWord och lämna accentutjämning avstängd, så att ekvivalens blir exakt och kodningsoberoende

För allt som ändrar dokumentet, lägg till soGraphemeClusters utan undantag. En sökning som returnerar ett något felaktigt intervall vilseleder bara en läsare; en ersättning eller redigering som använder samma felaktiga intervall skriver misstaget in i filen. Konsekvenserna av att få borttagningsintervall fel täcks i äkta redigering och innehållsborttagning

När genomströmning spelar roll, föredra batchingångspunkterna. SearchTextBatch kör varje icke-tom fråga medan varje sidas textblock är resident, vilket undviker att omextrahera en sida per fråga och återanvänder den cachade normaliseringen, och de strömmande varianterna sänder ut träffar utan en anropstilldelad buffert. Extraktionsmodellen under beskrivs i textsökning och sidelementsuppräkning

Skript där detta inte är valfritt

För koreanska är kanonisk ekvivalens skillnaden mellan att hitta ett namn och inte hitta det, eftersom förkomponerade stavelser och uppdelade jamo båda är vanliga i verkliga dokument. För vietnamesiska gör staplade diakritiska tecken sammansättningsformen helt producentberoende. För indiska skript avgör konjunkthantering om en träffgräns hamnar på en läsbar plats. För japanska och kinesiska är söksidan jämförelsevis enkel, även om layoutsidan inte är det, vilket beskrivs i vertikal skrift för japanska och kinesiska

Tumregeln är kort: om underlaget innehåller något annat språk än engelska, slå på kanonisk ekvivalens och mät kostnaden innan du bestämmer att den är för hög. I de flesta dokumentmängder är den inte det, och alternativet är en sökfunktion som tyst misslyckas på precis de namn dina användare mest bryr sig om att hitta

Unicode-medveten sökning, extraktion, redigering och textomskrivning delar en motor för Delphi, C++Builder och Free Pascal; den fullständiga funktionslistan finns på sidan för PDF Library for Delphi