PDF Library for Delphi kann Text nach kanonischer Äquivalenz statt nach Code-Einheit abgleichen, sodass eine als vorkomponiertes Zeichen eingetippte Suchanfrage Inhalt findet, der als Basisbuchstabe plus kombinierendes Zeichen gespeichert ist, und umgekehrt. Zwei Suchoptionen steuern dies: soCanonicalEquivalent aktiviert Unicode-Normalisierung beim Abgleich, und soGraphemeClusters beschränkt jeden Treffer und jeden Wildcard-Schritt auf vollständige Graphemcluster
Der hierdurch behobene Fehler ist einer der am häufigsten gemeldeten und am wenigsten verstandenen in der Dokumentsuche. Ein Nutzer sucht nach einem Namen, sieht keine Ergebnisse, kopiert den Namen aus dem Dokument, fügt ihn in das Suchfeld ein und findet ihn. Nichts ist auf offensichtliche Weise defekt: Die beiden Zeichenketten sehen identisch aus, drucken sich identisch, und vergleichen ungleich, weil die eine U+00E9 ist und die andere U+0065 gefolgt von U+0301
Warum vergleicht sich dasselbe Wort als ungleich?
Unicode erlaubt mehrere Kodierungen für dasselbe abstrakte Zeichen. Lateinische Buchstaben mit Diakritika existieren als vorkomponierte Codepunkte und als Basis-plus-Kombinationszeichen-Sequenzen. Hangul-Silben existieren als vorkomponierte Silben und als zerlegte Jamo. Welche Variante ein PDF enthält, hängt vom Erzeuger, der Plattform und mitunter der Schrift ab, und nichts davon ist für die suchende Person sichtbar
Der Grund, warum einfaches Case-Folding dies nicht löst, ist struktureller, nicht zufälliger Natur. Case-Folding und Akzentfaltung sind auf Code-Einheiten-Ebene eins-zu-eins: Die gefaltete Zeichenkette hat dieselbe Länge wie das Original, sodass eine Trefferposition im gefalteten Text eine Trefferposition im Original ist. Normalisierung ist nicht eins-zu-eins. Ein vorkomponiertes Zeichen wird zu zwei oder drei Code-Einheiten, eine zerlegte Sequenz kollabiert zurück zu einer, und nach dieser Transformation stimmen Positionen nicht mehr mit dem extrahierten Text überein
Trefferkoordinaten zeigen weiter auf den Originaltext
Dies ist der Teil, der darüber entscheidet, ob normalisierte Suche nutzbar ist statt nur korrekt. Jede durch Normalisierung erzeugte Code-Einheit hält die Start- und Endposition des ursprünglichen UTF-16-Texts fest, aus dem sie hervorging. Rekursive Dekompositionen erben den Quellbereich ihres Elternteils, Kompositionen verschmelzen die Bereiche ihrer Eingaben, und wird ein Treffer gefunden, durchsucht die Bibliothek das Abbildungsintervall nach dem kleinsten Start und größten Ende
Der Effekt ist, dass MatchStart, MatchLength, die Kontextzeichenketten und beide Ersetzungs-Einstiegspunkte weiterhin den ursprünglichen extrahierten Text adressieren, nicht das normalisierte Zwischenergebnis. Ohne diese Abbildung könnte eine normalisierte Suche zwar sagen, dass ein Treffer existiert, aber nicht zuverlässig, wo er lag, was Hervorhebung falsch und Schwärzung gefährlich macht
Der Normalisierer selbst ist eigenständig: kompakte Tabellen für kanonische Dekomposition, Komposition und kanonische Kombinationsklasse aus Unicode 15.1, wobei Hangul durch die algorithmischen Regeln statt durch Tabelleneinträge behandelt wird. Nichts wird aus einer externen Datendatei geladen, und keine Plattform-Normalisierungs-API wird aufgerufen, sodass ein Windows-Dienst, ein Linux-Daemon und ein FPC-Build bei derselben Eingabe identische Ergebnisse liefern
Mit kanonischer Äquivalenz suchen
Optionen bilden eine Menge, daher kombiniert sich kanonische Äquivalenz mit den vorhandenen Verhaltensweisen wie Ganzwort-Abgleich, Wildcards und diakritik-unempfindlicher Faltung:
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); // leerer Seitenbereich = gesamtes Dokument
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;
Normalisierung ist aus gutem Grund eine Opt-in-Funktion. Den NFD-Text und dessen Positionsabbildung aufzubauen kostet Aufwand, und die meisten Suchen über rein ASCII-basierte Dokumente brauchen das nie. Wird die Option verwendet, speichert jeder Textblock zwei transformierte Formen zwischen, eine mit entfernten Kombinationszeichen und eine ohne, sodass eine Reihe von Suchanfragen über denselben Block einmal statt einmal pro Suchanfrage normalisiert. Case-Folding durchläuft weiterhin unverändert den günstigeren Eins-zu-eins-Pfad
Was ohne Graphemcluster-Grenzen zerbricht
Code-Einheiten sind keine Zeichen, und Zeichen sind nicht das, was Nutzer wahrnehmen. Ein Flaggen-Emoji besteht aus zwei Regional-Indicator-Codepunkten. Ein Familien-Emoji besteht aus mehreren, durch Zero-Width-Joiner verbundenen Codepunkten. Ein indischer Konjunkt ist ein Konsonant, ein Virama und ein weiterer Konsonant. Ein Buchstabe mit zwei gestapelten Akzenten ist drei Codepunkte. Ein Abgleich oder Schnitt mitten in einem davon erzeugt ein Fragment, das als Datenmüll dargestellt wird
soGraphemeClusters beschränkt beide Enden jedes Treffers, ob wörtlich oder per Wildcard, auf vollständige erweiterte Graphemcluster-Grenzen. Die Segmentierung setzt die erweiterten Regeln um: CR-LF-Paarung, Steuerzeichen, Hangul-Silbenklassen, Extend und SpacingMark, Prepend, Emoji-ZWJ-Sequenzen, Regional-Indicator-Paarung und indische Konjunkt-Trennungen. Eine Grenze wird niemals innerhalb eines Surrogatpaars erzeugt, was allein eine ganze Klasse fehlerhafter Ergebnisse bei jedem Inhalt jenseits der Basic Multilingual Plane ausschließt
Die Option regelt zudem den Wildcard-Verbrauch, wo eine naive Implementierung weiterhin falsch schneiden würde. Der Einzelzeichen-Wildcard rückt exakt um einen vollständigen Cluster vor, und Backtracking für den Lauf-Wildcard bewegt sich nur zwischen Cluster-Grenzen:
// Ohne soGraphemeClusters kann "?" einen halben Cluster verbrauchen und
// einen Treffer zurückgeben, dessen Text mit einem baumelnden
// Kombinationszeichen endet
Found := Lib.SearchText('c?té',
[soWildcards, soCanonicalEquivalent, soGraphemeClusters], '', Hits);
// Dieselben Grenzen schützen auch die Ersetzung, sodass Schwärzung
// und Inhaltsumschreibung nie ein Emoji oder einen akzentuierten
// Buchstaben teilen
Replaced := Lib.SearchAndReplaceText('naïve', 'plain',
[soCanonicalEquivalent, soGraphemeClusters], '1-20');
Optionen für einen realen Arbeitsablauf wählen
Drei Kombinationen decken die meisten Fälle ab. Für ein internes Dokumentsuchfeld liefert soCanonicalEquivalent zusammen mit soDiacriticInsensitive das nachsichtige Verhalten, das Nutzer erwarten, indem beide Kodierungsformen sowie akzentuierte und unakzentuierte Schreibweisen abgeglichen werden. Für Rechts- oder Compliance-Suche, wo ein falsch-positives Ergebnis Kosten verursacht, soCanonicalEquivalent mit soCaseSensitive und soWholeWord verwenden und Akzentfaltung deaktiviert lassen, sodass Äquivalenz exakt und kodierungsunabhängig bleibt
Bei allem, was das Dokument verändert, sollte soGraphemeClusters ausnahmslos hinzugefügt werden. Eine Suche, die einen leicht falschen Bereich zurückgibt, führt einen Leser nur in die Irre; eine Ersetzung oder Schwärzung, die denselben falschen Bereich verwendet, schreibt den Fehler in die Datei. Die Folgen falscher Entfernungsbereiche werden in echter Schwärzung und Inhaltsentfernung behandelt
Wenn Durchsatz zählt, sollten die Batch-Einstiegspunkte bevorzugt werden. SearchTextBatch führt jede nicht leere Suchanfrage aus, während die Textblöcke jeder Seite im Speicher vorliegen, was ein erneutes Extrahieren einer Seite pro Suchanfrage vermeidet und die zwischengespeicherte Normalisierung wiederverwendet, und die Streaming-Varianten geben Treffer ohne einen vom Aufrufer dimensionierten Puffer aus. Das zugrundeliegende Extraktionsmodell wird in Textsuche und Seitenelement-Enumeration beschrieben
Schriftsysteme, bei denen dies nicht optional ist
Für Koreanisch ist kanonische Äquivalenz der Unterschied zwischen dem Finden und Nicht-Finden eines Namens, weil vorkomponierte Silben und zerlegte Jamo in echten Dokumenten beide üblich sind. Für Vietnamesisch machen gestapelte Diakritika die Kompositionsform vollständig erzeugerabhängig. Für indische Schriftsysteme entscheidet die Konjunkt-Behandlung, ob eine Trefferbegrenzung an einer lesbaren Stelle landet. Für Japanisch und Chinesisch ist die Suchseite vergleichsweise einfach, die Layoutseite hingegen nicht, wie in vertikaler Schreibrichtung für Japanisch und Chinesisch beschrieben
Die Faustregel ist kurz: Enthält der Bestand irgendeine Sprache außer Englisch, sollte kanonische Äquivalenz aktiviert und der Aufwand gemessen werden, bevor man ihn für zu teuer hält. Bei den meisten Dokumentbeständen ist er das nicht, und die Alternative ist eine Suchfunktion, die still bei genau den Namen versagt, deren Auffindbarkeit den Nutzern am wichtigsten ist
Unicode-bewusste Suche, Extraktion, Schwärzung und Textumschreibung teilen sich eine Engine für Delphi, C++Builder und Free Pascal; die vollständige Funktionsliste findet sich auf der PDF-Library-für-Delphi-Seite