09 April 2025

ICU4X: Was es kann und wie es geht

Einführung

Wenn Sie regelmäßig mit Code zu tun haben, der umfangreiche Internationalisierungsfunktionen benötigt, ist es wahrscheinlich, dass Sie bereits Funktionalitäten aus einer der ICU-Bibliotheken verwendet haben. Vom Unicode-Konsortium entwickelt, bietet ICU zuverlässige, ausgereifte und umfangreiche Implementierungen für alle Arten von Tools zur Internationalisierung und für Unicode-Textoperationen. Traditionell gab es zwei Implementierungen von ICU: ICU4C, implementiert in C, und ICU4J, implementiert in Java. Diese Bibliotheken waren viele Jahre lang der Goldstandard für die korrekte Unicode-Textverarbeitung und i18n. Seit einigen Jahren entwickelt das Unicode-Konsortium jedoch an ICU4X, einer relativ neuen Implementierung in Rust.

Der Fokus von ICU4X liegt auf der Verfügbarkeit auf vielen Plattformen und in vielen Programmiersprachen. Während ältere Implementierungen wie ICU4C und ICU4J sehr ausgereift sind und derzeit mehr Funktionalität als ICU4X bieten, weisen diese Bibliotheken einen sehr großen Codeumfang und einen hohen Laufzeit-Speicherbedarf auf, was ihren Einsatz in ressourcenbeschränkten Umgebungen wie Webbrowsern oder auf mobilen oder eingebetteten Geräten unpraktikabel macht. ICU4X achtet darauf, den Codeumfang der Bibliothek zu reduzieren und bietet zusätzliche Möglichkeiten zur Optimierung des Codeumfangs sowohl der Bibliothek selbst als auch der mit einer Anwendung gelieferten Unicode-Daten.

In diesem Artikel werde ich einen Überblick darüber geben, was ICU4X kann und wie es funktioniert. Wenn Sie bereits mit anderen ICU-Implementierungen gearbeitet haben, werden sich viele davon wahrscheinlich vertraut anfühlen; wenn Sie hingegen noch nie mit ICU in Berührung gekommen sind, sollte dieser Artikel Ihnen eine gute Einführung in die Durchführung verschiedener Unicode-Textoperationen mit ICU4X geben.

Voraussetzungen

Ich werde viele Codebeispiele zur Verwendung von ICU4X in Rust zeigen. Obwohl es nicht unbedingt notwendig sein sollte, Rust zu verstehen, um die Grundlagen dessen zu erfassen, was vor sich geht, wird eine gewisse Vertrautheit mit der Sprache definitiv helfen, die feineren Details zu verstehen. Wenn Sie mit Rust nicht vertraut sind und mehr erfahren möchten, empfehle ich The Rust Book als Einführung.

In den Beispielen werde ich mich auf verschiedene Funktionen und Typen aus ICU4X beziehen, ohne deren Typen im Detail zu zeigen. Fühlen Sie sich frei, die API-Dokumentation neben diesem Artikel zu öffnen, um die Typen der erwähnten Funktionen nachzuschlagen.

Test Setup

Wenn Sie das Beispiel selbst ausführen möchten, empfehle ich, ein Cargo-Projekt mit der entsprechenden Abhängigkeit einzurichten:

$ cargo new --bin icu4x-blog
$ cd icu4x-blog
$ cargo add icu

Dies initialisiert ein grundlegendes Cargo.toml und src/main.rs. Nun können Sie beliebigen Beispielcode in die generierten main Funktionen innerhalb von main.rs einfügen und Ihre Beispiele mit cargo run ausführen:

$ cargo run
 Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.02s
 Running `target/debug/icu4x-blog`
Hello, world!

Vorerst gibt dies nur die von Cargo generierte Standardmeldung „Hello, world!“ aus. Fügen wir nun unsere eigenen Beispiele hinzu.

Locales

Das Verhalten einiger Operationen von ICU4X hängt vom sprachlichen oder kulturellen Kontext ab. Wenn dies der Fall ist, müssen wir angeben, welchen sprachlichen oder kulturellen Hintergrund wir wünschen. Dies geschieht in Form sogenannter Locales. Im Kern wird ein Locale durch eine kurze Zeichenkette identifiziert, die eine Sprache und Region angibt. Sie sehen normalerweise so aus wie „en-US“ für ein amerikanisches Englisch-Locale oder „de-AT“ für ein deutsches Sprach-Locale, wie es in Österreich gesprochen wird.

Locales tun für sich genommen nichts Aufregendes. Sie teilen anderen Operationen lediglich mit, wie sie sich verhalten sollen, daher ist die Konstruktion im Grunde das Einzige, was wir mit Locales tun. Es gibt zwei Hauptwege, ein Locale zu konstruieren. Wir können das locale! Makro verwenden, um ein statisches Locale wie folgt zu konstruieren und zu validieren:

let en_us = icu::locid::locale!("en-US");
println!("{en_us}");

Oder wir können versuchen, ein Locale zur Laufzeit aus einer Zeichenkette zu parsen:

let de_at = "de-AT".parse::<icu::locid::Locale>().unwrap();
println!("{de_at}");

Beachten Sie, dass das Parsen eines Locales bei ungültigen Eingaben fehlschlagen kann. Dies wird dadurch kodiert, dass die parse Funktion ein Result<Locale, ParserError> zurückgibt. Im obigen Beispiel verwenden wir unwrap, um die Möglichkeit eines Fehlers zu ignorieren, was bei tatsächlich ungültigen Eingaben zu einem Panic führt:

let invalid_locale = "Invalid!".parse::<icu::locid::Locale>().unwrap();
println!("{invalid_locale}");

Zusammengenommen ergeben diese Beispiele die folgende Ausgabe:

$ cargo run
[...]
en-US
de-AT

thread 'main' panicked at src/main.rs:8:67:
called `Result::unwrap()` on an `Err` value: InvalidLanguage
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

In praktischen Szenarien werden Sie Locales wahrscheinlich dynamisch zur Laufzeit über einen Standardmechanismus Ihres Betriebssystems oder einer anderen Ausführungsplattform erkennen wollen. Leider gibt es derzeit keine standardisierte Methode, ICU4X-Locales aus externen Quellen zu erkennen, aber der Fortschritt bei der Implementierung einer solchen Lösung wird in diesem Issue verfolgt.

Nachdem wir uns angesehen haben, wie man Locales konstruiert, betrachten wir nun einige Operationen, die Locales benötigen, um zu funktionieren.

Kollation

Die erste Operation, die wir uns ansehen werden, ist die Kollation. Sie sind wahrscheinlich mit dem Konzept des lexikographischen Vergleichs von Zeichenketten vertraut. In Rust implementiert der str Typ bereits den Ord Trait, was uns einfache lexikographische Vergleiche und Sortierungen von Zeichenketten ermöglicht. Allerdings sind sich nicht alle Sprachen und Kulturen darüber einig, in welcher Reihenfolge Buchstaben sortiert werden sollen. Zum Beispiel wird in Deutschland der Buchstabe Ä normalerweise direkt nach A sortiert, während in Schweden der Buchstabe Ä normalerweise nach Z sortiert wird. Rusts Standardmethode zum Vergleich von Zeichenketten berücksichtigt diese regionalen Unterschiede nicht. ICU4X stellt uns Kollationsfunktionalität zur Verfügung, um Zeichenketten unter Berücksichtigung dieser kulturellen Unterschiede zu vergleichen und zu sortieren.

Konstruktion

Die erste Maßnahme dafür ist die Erstellung eines Collator wie folgt:

let de_de = icu::locid::locale!("de-DE");
let collator_de = icu::collator::Collator::try_new(&de_de.into(), Default::default()).unwrap();

Der erste Parameter ist das Locale, für das wir eine Kollation wünschen. Oder technisch gesehen ist es ein DataLocale, denn das ist es, was try_new möchte. Der Unterschied muss uns im Moment nicht allzu sehr beschäftigen. Wissen Sie einfach, dass wir ein Locale in ein DataLocale mit .into() konvertieren können. Der zweite Parameter ist eine CollatorOptions Struktur, die wir verwenden könnten, um spezifischere Optionen für die Kollation anzugeben. Wir werden hier nicht auf die spezifischen Optionen eingehen und stattdessen einfach die Standardoptionen verwenden, aber sehen Sie sich die API-Dokumentation an, wenn Sie neugierig sind, welche Optionen Sie angeben können. Zuletzt unwrap wir das Collator, da dessen Erstellung fehlschlagen kann, wenn keine Kollationsdaten für das gegebene Locale gefunden werden konnten. Wir werden später über diese Möglichkeit sprechen, wenn es um die Datenverarbeitung geht.

Nachdem wir nun einen Kollator für das de-DE Locale haben, erstellen wir einen weiteren für Schwedisch (sv-SE):

let sv_se = icu::locid::locale!("sv-SE");
let collator_sv = icu::collator::Collator::try_new(&sv_se.into(), Default::default()).unwrap();

Nutzung

Nachdem wir nun einige Kollatoren erstellt haben, sortieren wir einige Zeichenketten mit dem Standard-Rust-Vergleich und verschiedenen Locales, um die unterschiedlichen Ergebnisse zu sehen:

let mut strings = ["Abc", "Äbc", "ZYX"];
strings.sort_by(Ord::cmp);
println!("Rust default sorted: {strings:?}");
strings.sort_by(|a, b| collator_de.compare(a, b));
println!("Collated de-DE: {strings:?}");
strings.sort_by(|a, b| collator_sv.compare(a, b));
println!("Collated sv-SE: {strings:?}");

This produces the following output:

$ cargo run
[...]
Rust default sorted: ["Abc", "ZYX", "Äbc"]
Collated de-DE: ["Abc", "Äbc", "ZYX"]
Collated sv-SE: ["Abc", "ZYX", "Äbc"]

Wie vorhergesagt, sortierte die deutsche Kollation die Zeichenketten anders als die schwedische Kollation. Zufälligerweise sortierte die Standard-Rust-Reihenfolge diese spezifischen Zeichenketten genauso wie die schwedische Kollation, obwohl Sie sich in der Praxis nicht auf solche Zufälle verlassen sollten und immer die korrekte Kollation verwenden sollten, wenn Sie Zeichenketten für Anzeigezwecke sortieren.

Kalender

Manchmal vergisst man leicht, dass nicht alle Kulturen dasselbe Kalendersystem verwenden. Und selbst verschiedene Kulturen, die dasselbe Kalendersystem teilen, verwenden möglicherweise unterschiedliche Formate zur Darstellung von Daten. ICU4X bietet Unterstützung für die Konvertierung zwischen Darstellungen verschiedener Kalender und deren Formatierung nach lokalem Geschmack. Neben dem gregorianischen Kalender, der in den meisten Regionen der Welt beliebt ist, und dem ISO-Kalender, der oft für technische Zwecke verwendet wird, werden viele andere Kalender wie der japanische, äthiopische oder indische Kalender unterstützt. Derzeit wird jedoch keine Funktionalität zum Abrufen der aktuellen Zeit bereitgestellt, sodass Sie in realen Anwendungen zuerst von einer anderen Darstellung konvertieren müssen.

Konstruktion

Im nächsten Beispiel haben wir ein bekanntes Datum, das als ISO-Datum angegeben ist und das wir in einem bestimmten Locale anzeigen möchten:

let iso_date = icu::calendar::Date::try_new_iso_date(1978, 3, 8).unwrap();

Als Nächstes erstellen wir ein DateFormatter:

let local_formatter = icu::datetime::DateFormatter::try_new_with_length(
 &icu::locid::locale!("th").into(),
 icu::datetime::options::length::Date::Medium,
)
.unwrap();

Wir werden den Formatierer verwenden, um Daten in eine locale-spezifische Textdarstellung zu formatieren. Während der Erstellung können wir ein Locale auswählen (oder vielmehr wieder ein DataLocale). Wir wählen das thailändische Locale, weil es im Gegensatz zu den meisten Teilen der Welt den buddhistischen Kalender anstelle des gregorianischen Kalenders verwendet. Wir können auch eine Formatlänge wählen, die uns eine gewisse Kontrolle über die Länge des Datumsformats gibt. Wir verwenden die mittlere Länge, die abgekürzte Monatsnamen verwendet, falls diese im Locale verfügbar sind, oder andernfalls numerische Monate.

Formatierung

Nun formatieren wir einfach das Datum und geben es aus:

let local_format = local_formatter.format(&iso_date.to_any()).unwrap();
println!("{local_format}");

Was uns diese Ausgabe liefert:

$ cargo run
[...]
8 มี.ค. 2521

Wenn Sie, wie ich, nicht gut vertraut sind mit dem buddhistischen Kalender und thailändischen Monatsnamen, wird Ihnen das wahrscheinlich nicht viel sagen. Aber genau das ist der Sinn der Verwendung einer i18n-Bibliothek wie ICU4X. Wir können allgemeine Operationen verwenden, die für jedes unterstützte Locale das Richtige tun, ohne die Feinheiten jedes spezifischen Locales verstehen zu müssen.

Beim Aufruf von format müssen Sie darauf achten, ein Datum zu übergeben, das zu einem für das Locale des Formatierers geeigneten Kalender gehört. Obwohl sein Parametertyp suggeriert, dass ein Datum aus jedem Kalender verwendet werden kann, akzeptiert die Operation nur Daten aus dem ISO-Kalender oder Daten aus dem korrekten Kalender für dieses Locale (d.h. in diesem Beispiel hätten wir auch ein Datum übergeben können, das bereits gemäß dem buddhistischen Kalender formatiert war). In diesem Fall haben wir ein ISO-Datum verwendet, das immer akzeptiert wird. Wenn Sie ein Datum in einem völlig anderen Kalender haben, wird es komplizierter. Sie müssten Ihr Datum explizit in den korrekten Zielkalender konvertieren und es dann an format übergeben. Dafür erhalten Sie den benötigten Kalender mit AnyCalendar::new_for_locale und führen die Konvertierung mit Date::to_calendar durch.

Normalization

Unicode-Texte werden als eine Abfolge von Zahlen, genannt Codepunkte, dargestellt. Aber nicht jeder Codepunkt hat seine eigene atomare Bedeutung. Einige Sequenzen von Codepunkten verbinden sich zu Gruppen, um komplexere Zeichen darzustellen. Aufgrund dieser Komplexität ist es in vielen Fällen möglich, dass unterschiedliche Sequenzen von Codepunkten dieselbe Sequenz semantischer Zeichen darstellen. Zum Beispiel kann der Buchstabe Ä als einzelner Codepunkt U+00C4 oder als Sequenz der Codepunkte U+0041 U+0308 (ein A gefolgt von der Kombination zweier Punkte darüber) dargestellt werden. Dies hat Auswirkungen, wenn wir Zeichenketten auf Gleichheit vergleichen wollen. Naiv könnten wir Zeichenketten vergleichen wollen, indem wir prüfen, ob jeder der Codepunkte gleich ist. Das würde aber bedeuten, dass Zeichenketten, die sich unterschiedlich vergleichen lassen, weil sie unterschiedliche Codepunkte enthalten, tatsächlich die semantisch gleichen Zeichen enthalten.

Um mit dieser Situation umzugehen, bietet uns ICU4X die Zeichenkettennormalisierung. Die Idee ist wie folgt: Bevor wir Zeichenketten miteinander vergleichen, „normalisieren“ wir jede Zeichenkette. Die Normalisierung transformiert die Zeichenkette in eine normalisierte Darstellung, wodurch sichergestellt wird, dass alle semantisch gleichen Zeichenketten auch dieselbe normalisierte Darstellung haben. Das bedeutet, dass, sobald wir die zu vergleichenden Zeichenketten normalisiert haben, wir die resultierenden Zeichenketten einfach Codepunkt für Codepunkt vergleichen können, um festzustellen, ob die ursprünglichen Zeichenketten semantisch gleich waren.

Normalisierungsformen

Bevor wir diese Normalisierung durchführen können, müssen wir verstehen, dass es mehrere Normalisierungsformen gibt. Diese Formen unterscheiden sich durch zwei Eigenschaften. Auf der einen Achse können sie komponierend oder dekomponierend sein. Auf der anderen Achse können sie kanonisch oder kompatibel sein.

Komponierende Normalisierungsformen stellen sicher, dass die normalisierte Form so wenige Codepunkte wie möglich hat, z.B. würde für den Buchstaben Ä die Einzelcodepunktform verwendet. Dekomponierende Normalisierung hingegen wählt immer die Darstellung, die die meisten verfügbaren Codepunkte erfordert, z.B. würde für den Buchstaben Ä die Zweicodepunktform verwendet. Mit komponierender Normalisierung benötigen wir weniger Speicherplatz, um die normalisierte Form zu speichern. Allerdings ist komponierende Normalisierung auch normalerweise langsamer auszuführen als dekomponierende Normalisierung, da komponierende Normalisierung intern zuerst dekomponierende Normalisierung ausführen und dann das Ergebnis komprimieren muss. Als Faustregel wird normalerweise empfohlen, dass komponierende Normalisierung verwendet wird, wenn die normalisierten Zeichenketten auf der Festplatte gespeichert oder über das Netzwerk gesendet werden, während dekomponierende Normalisierung verwendet werden sollte, wenn die normalisierte Form nur intern innerhalb einer Anwendung verwendet wird.

Kanonische Normalisierung betrachtet nur unterschiedliche Codepunkt-Darstellungen derselben Zeichen als gleich. Kompatible Normalisierung geht einen Schritt weiter und betrachtet Zeichen, die dieselbe Bedeutung vermitteln, sich aber in der Darstellung unterscheiden, als gleich. Zum Beispiel werden unter kompatibler Normalisierung die Zeichen „2“, „²“ und „②“ alle als gleich betrachtet, während sie unter kanonischer Normalisierung unterschiedlich sind. Kompatible Normalisierung kann angemessen sein, wenn Bezeichner wie Benutzernamen normalisiert werden, um ähnliche, aber unterschiedliche Duplikate zu erkennen.

Zusammengenommen ergibt dies vier verschiedene mögliche Normalisierungsformen:

  • NFC: komponierend und kanonisch
  • NFKC: komponierend und kompatibel
  • NFD: dekomponierend und kanonisch
  • NFKD: dekomponierend und kompatibel

Normalisierung durchführen

Sobald wir uns für eine zu verwendende Normalisierungsform entschieden haben, ist die eigentliche Durchführung der Normalisierung einfach. Hier ist ein Beispiel mit NFD-Normalisierung:

let string1 = "\u{00C4}";
let string2 = "\u{0041}\u{0308}";

let rust_equal = string1 == string2;

let normalizer = icu::normalizer::DecomposingNormalizer::new_nfd();
let normalized1 = normalizer.normalize(string1);
let normalized2 = normalizer.normalize(string2);
let normalized_equal = normalized1 == normalized2;

println!(
 "1: {string1}, 2:  {string2}, rust equal: {rust_equal}, normalized equal: {normalized_equal}"
)
$ cargo run
[...]
1: Ä, 2: Ä, rust equal: false, normalized equal: true

Wie wir sehen können, sehen string1 und string2 beim Drucken gleich aus, aber der == Operator betrachtet sie nicht als gleich. Normalisiert man jedoch beide Zeichenketten und vergleicht die Ergebnisse, werden sie als gleich verglichen.

NFKD-Normalisierung kann durch Konstruktion des Normalisierers mit DecomposingNormalizer::new_nfkd verwendet werden. NFC und NFKC sind jeweils mit ComposingNormalizer::new_nfc und ComposingNormalizer::new_nfkc zugänglich.

Segmentierung

Wenn wir Unicode-Texte betrachten, werden wir oft feststellen, dass sie nicht nur aus einzelnen Codepunkten bestehen, sondern aus größeren Konstrukten, die aus mehreren Codepunkten bestehen, wie Wörtern oder Zeilen. Bei der Textverarbeitung ist es oft notwendig zu erkennen, wo die Grenzen zwischen diesen einzelnen Teilen liegen. In ICU4X wird dieser Prozess Segmentierung genannt, und er stellt uns vier verschiedene Arten von Segmenten zur Erkennung zur Verfügung: Grapheme, Wörter, Sätze und Zeilen. Der Segmentierungsprozess ist für jeden sehr ähnlich, aber jeder von ihnen hat auch seine eigenen Besonderheiten, daher werden wir uns jeden einzeln ansehen.

Grapheme

Wie bereits erwähnt, verbinden sich einige Codepunkte mit anderen Codepunkten und erhalten dadurch eine andere Bedeutung, als jeder Codepunkt einzeln hätte. Wenn wir Zeichenketten zwischen zwei kombinierten Codepunkten trennen, können sich die Codepunkte nicht mehr verbinden und kehren somit zu ihrer individuellen Bedeutung zurück. Hier ist ein Beispiel für solche unbeabsichtigten Bedeutungsänderungen:

let string1 = "\u{61}\u{308}\u{6f}\u{308}\u{75}\u{308}";
let string2 = "stu";
println!("string1: {string1}, string2: {string2}");

let (split1, split2) = string1.split_at(4);
println!("split1: {split1}, split2: {split2}");
println!("combined: {string2}{split2}");
$ cargo run
[...]
string1: äöü, string2: stu
split1: äo, split2: ̈ü
combined: stüü

Beachten Sie zunächst, dass die Ausgabe von split1 und split2 zeigt, dass das, was zuvor ein ö war, nun in ein o und ein loses Paar von zwei Punkten aufgeteilt wurde. Noch schlimmer: Wenn wir string2 und split2 in einer einzigen Ausgabe kombinieren, verbinden sich die Punkte am Anfang von split2 mit dem letzten Zeichen von string2 und bilden ein zusätzliches „ü“, das nie existieren sollte.

Grapheme als Lösung

Woher wissen wir also, wo es sicher ist, eine Zeichenkette zu teilen, ohne die Bedeutung der enthaltenen Zeichen zu verändern? Zu diesem Zweck definiert Unicode das Konzept der Graphem-Cluster, welches eine Sequenz von Codepunkten ist, die zusammen eine einzige Bedeutung haben, aber von der Bedeutung der umgebenden Codepunkte unbeeinflusst bleiben. Solange wir darauf achten, Zeichenketten nur an den Grenzen zwischen Graphem-Clustern zu teilen, können wir sicher sein, die Semantik der in der Zeichenkette enthaltenen Zeichen nicht unbeabsichtigt zu ändern. Ähnlich sollten wir beim Erstellen einer Benutzeroberfläche für die Textbearbeitung oder Textauswahl darauf achten, dem Benutzer einen einzelnen Graphem-Cluster als eine einzige, unteilbare Einheit zu präsentieren.

Um herauszufinden, wo die Grenzen zwischen Graphem-Clustern liegen, stellt uns ICU4X das GraphemeClusterSegmenter zur Verfügung. Sehen wir uns an, wie es unsere frühere Zeichenkette segmentiert hätte:

let string = "\u{61}\u{308}\u{6f}\u{308}\u{75}\u{308}";
println!("string: {string}");
let grapheme_boundaries: Vec<usize> = icu::segmenter::GraphemeClusterSegmenter::new()
 .segment_str(string)
 .collect();
println!("grapheme boundaries: {grapheme_boundaries:?}");
$ cargo run
[...]
string: äöü
grapheme boundaries: [0, 3, 6, 9]

Wie wir sehen können, gibt die segment_str Funktion einen Iterator über Indizes zurück, an denen sich Grenzen zwischen Graphem-Clustern befinden. Natürlich ist der erste Index immer 0 und der letzte Index immer das Ende des Strings. Wir können auch sehen, dass der Index 4, an dem wir unseren String im letzten Beispiel geteilt haben, keine Grenze zwischen Graphem-Clustern war, und somit unsere Teilung die beobachtete Bedeutungsänderung verursachte. Hätten wir den String stattdessen bei den Indizes 3 oder 6 geteilt, hätten wir nicht die gleichen Probleme gehabt.

Words

Manchmal ist es hilfreich, einen String in seine einzelnen Wörter zu zerlegen. Dafür erhalten wir die treffend benannte WordSegmenter. Also legen wir gleich los:

let string = "Hello world";
println!("string:  {string}");
let word_boundaries: Vec<usize> = icu::segmenter::WordSegmenter::new_auto()
  .segment_str(string)
  .collect();
println!("word boundaries: {word_boundaries:?}");
$ cargo run
[...]
string: Hello world
word boundaries: [0, 5, 6, 11]

Bisher ist dies dem GraphemeClusterSegmenter, den wir zuvor gesehen haben, sehr ähnlich. Was aber, wenn wir die Wörter selbst und nicht nur ihre Grenzen wollen? Wir können einfach über Fenster von jeweils zwei Grenzen iterieren und den ursprünglichen String zerlegen:

let words: Vec<&str> = word_boundaries
 .windows(2)
 .map(|bounds| &string[bounds[0]..bounds[1]])
  .collect();
println!("words: {words:?}");
$ cargo run
[...]
words: ["Hello", " ", "world"]

Das sieht besser aus. Es liefert uns die beiden Wörter, die wir erwarten. Es liefert uns auch die Leerzeichen zwischen den Wörtern. Wenn wir das nicht wollen, können wir den WordSegmenter fragen, ob eine gegebene Grenze nach einem echten Wort oder nur nach einem Leerzeichen kommt, und danach filtern:

let word_boundaries: Vec<(usize, icu::segmenter::WordType)> =
 icu::segmenter::WordSegmenter::new_auto()
  .segment_str(string)
 .iter_with_word_type()
  .collect();
println!("word boundaries: {word_boundaries:?}");
let words: Vec<&str>  = word_boundaries
  .windows(2)
 .filter_map(|bounds| {
 let (start, _) = bounds[0];
 let (end, word_type) = bounds[1];
 if word_type.is_word_like() {
 Some(&string[start..end])
 } else {
 None
  }
  })
  .collect();
println!("words: {words:?}");
$ cargo run
[...]
word boundaries: [(0, None), (5, Letter), (6, None), (11, Letter)]
words: ["Hello", "world"]

Falls Sie sich gefragt haben, warum der Konstruktor für WordSegmenter new_auto genannt wird, liegt es daran, dass es mehrere Algorithmen zur Wortsegmentierung gibt, aus denen man wählen kann. Es gibt auch new_dictionary und new_lstm, und nicht jeder Algorithmus funktioniert für verschiedene Schriftsysteme gleichermaßen gut. new_auto ist im Allgemeinen eine gute Wahl, da es automatisch eine gute Implementierung basierend auf den tatsächlich im String gefundenen Daten auswählt.

Sätze

Wenn wir Strings in Sätze zerlegen wollen, SentenceSegmenter tut genau das. Es gibt nicht viel Besonderes daran, also legen wir gleich los:

let string = "here is a sentence. This is another sentence.";
println!("string:  {string}");
let sentence_boundaries: Vec<usize> = icu::segmenter::SentenceSegmenter::new()
  .segment_str(string)
  .collect();
println!("sentence boundaries: {sentence_boundaries:?}");
let words: Vec<&str> = sentence_boundaries
  .windows(2)
  .map(|bounds| &string[bounds[0]..bounds[1]])
  .collect();
println!("words: {words:?}");
$cargo run
[...]
string: here is a sentence. This is another sentence. 
sentence boundaries: [0, 20, 45]
words: ["here is a sentence. ", "This is another sentence."]

Keine Überraschungen, weiter geht es.

Zeilen

Der LineSegmenter identifiziert Grenzen, an denen Strings in mehrere Zeilen aufgeteilt werden können. Sehen wir uns ein Beispiel an:

let string = "The first line.\nThe\u{a0}second line.";
println!("string:  {string}");
let line_boundaries: Vec<usize> = icu::segmenter::LineSegmenter::new_auto()
  .segment_str(string)
  .collect();
println!("line boundaries: {line_boundaries:?}");
let lines: Vec<&str> = line_boundaries
  .windows(2)
  .map(|bounds| &string[bounds[0]..bounds[1]])
  .collect();
println!("lines: {lines:?}");
$ cargo run
[...]
string: The first line.
The second line.
line boundaries: [0, 4, 10, 16, 28, 33]
lines: ["The ", "first ", "line.\n", "The\u{a0}second ", "line."]

Dies liefert uns mehr einzelne „Zeilen“, als wir vielleicht zuvor erwartet hätten. Das liegt daran, dass der LineSegmenter uns nicht nur Grenzen für bereits im String enthaltene Zeilenumbrüche liefert, sondern auch Grenzen an Stellen, an denen ein weicher Zeilenumbruch platziert werden könnte. Dies kann sehr nützlich sein, wenn Sie einen langen String über mehrere Zeilen umbrechen möchten.

Wenn Sie unterscheiden möchten, ob eine gegebene Grenze ein harter Zeilenumbruch ist, der im String enthalten ist, oder nur eine Möglichkeit für einen optionalen Zeilenumbruch, können Sie das Zeichen direkt vor dem Zeilenumbruch mithilfe von icu::properties::maps::line_break prüfen.

Fallzuordnung

Bei der Verarbeitung von Unicode-Texten besteht manchmal die Notwendigkeit, Buchstaben zwischen Klein- und Großbuchstaben umzuwandeln. ICU4X bietet uns verschiedene Werkzeuge dafür, also schauen wir uns jedes einzelne an.

GROSSBUCHSTABEN und Kleinbuchstaben

Die Umwandlung in Klein- und Großbuchstaben sind oberflächlich betrachtet sehr einfache Operationen. Sie ähneln den eingebauten str::to_lowercase– und str::to_uppercase-Methoden von Rust. Sehen wir uns also an, warum ICU4X eine separate Unterstützung dafür bietet:

let string = "AaBbIıİi";
println!("string:  {string}");

let locale = icu::locid::locale!("de-DE");

let cm = icu::casemap::CaseMapper::new();
let lower = cm.lowercase_to_string(string, &locale.id);
let upper = cm.uppercase_to_string(string, &locale.id);
println!("lower: {lower}, upper: {upper}");
$cargo run
[...]
string: AaBbIıİi
lower: aabbiıi̇i, upper: AABBIIİI

Bisher sieht dies wie die bekannten Klein- und Großschreibungsoperationen aus den Standardbibliotheken der meisten Sprachen aus. Beachten Sie jedoch, dass wir locale.id angeben mussten, um diese Operationen auszuführen. Der Clou dabei ist, dass die Regeln für Klein- und Großschreibung je nach Sprache variieren können, was sich in den Varianten dieser Operationen von ICU4X widerspiegelt. Beachten Sie, wie sich das Ergebnis ändert, wenn wir das Gebietsschema tr-TR anstelle von de-DE verwenden:

$ cargo run
[...]
string: AaBbIıİi
lower: aabbııii, upper: AABBIIİİ

Mit ICU4X müssen wir die Details nicht kennen, wie verschiedene Klein- und Großbuchstaben in verschiedenen Sprachen zusammenpassen. Solange wir das richtige Gebietsschema übergeben, wird ICU4X das Richtige tun.

Beachten Sie jedoch, dass Groß- und Kleinschreibungsoperationen nur für Anzeigezwecke gedacht sind. Wenn Sie Strings ohne Berücksichtigung der Groß-/Kleinschreibung vergleichen möchten, benötigen Sie stattdessen Case Folding, das wir später behandeln werden.

Titel-Großschreibung

Titel-Großschreibung ist der Prozess, den ersten Buchstaben eines Segments in Großbuchstaben und alle anderen Zeichen in Kleinbuchstaben umzuwandeln. Wenn wir zum Beispiel jedes Wort in einem String mit Titel-Großschreibung versehen wollten, würden wir zuerst einen WordSegmenter verwenden, um jedes Wort zu extrahieren, und dann einen TitlecaseMapper, um die Titel-Großschreibung für jedes Wort durchzuführen.

let string = "abc DŽ 'twas words and more wORDS";
println!("string:  {string}");

let locale = icu::locid::locale!("de-DE");

let cm = icu::casemap::TitlecaseMapper::new();
let word_segments: Vec<usize>  = icu::segmenter::WordSegmenter::new_auto()
  .segment_str(string)
  .collect();

let titlecased: String = word_segments
 .windows(2)
 .map(|bounds| {
 let word = &string[bounds[0]..bounds[1]];
 cm.titlecase_segment_to_string(word, &locale.id, Default::default())
  })
  .collect();
println!("titlecased: {titlecased}");
$ cargo run
[...]
string: abc DŽ 'twas words and more wORDS
titlecased: Abc Dž 'Twas Words And More Words

Auch hier mussten wir &locale.id angeben, um festzulegen, welche sprachspezifischen Regeln bei Falltransformationen zu beachten sind. Zusätzlich können wir weitere Optionen als dritten Parameter übergeben. Hier haben wir die Standardoptionen verwendet, aber schauen Sie sich gerne die API-Dokumentation an, um zu sehen, welche weiteren Optionen unterstützt werden.

Beachten Sie, wie DŽ in Dž umgewandelt wurde, obwohl es sich um einen einzelnen Buchstaben handelt, dessen reguläre Großbuchstabenform DŽ ist. Dies liegt daran, dass jedes Zeichen separate Groß- und Titel-Großschreibungsformen hat, die bei den meisten lateinischen Zeichen zufällig gleich sind. Beachten Sie auch, dass 'twas in 'Twas umgewandelt wurde. Dies liegt daran, dass der TitlecaseMapper den ersten Buchstaben eines Wortes in Titel-Großschreibung umwandelt und dabei nicht-buchstabische Zeichen am Wortanfang überspringt.

Case Folding

Manchmal möchten wir feststellen, ob zwei Strings gleich sind, wobei Unterschiede in der Groß-/Kleinschreibung ignoriert werden. Traditionell wurde dies durch die Umwandlung beider Strings in Klein- oder Großbuchstaben erreicht, um Unterschiede in der Groß-/Kleinschreibung zu eliminieren und diese Strings zu vergleichen. Bei Unicode-Strings reicht für einige Zeichen eine einfache Klein- oder Großschreibung nicht aus, um alle Unterschiede in der Groß-/Kleinschreibung zu eliminieren. Als Beispiel wird der deutsche Buchstabe ß zu SS großgeschrieben, aber es gibt auch eine Großbuchstabenversion von ß: ẞ, die sich selbst großschreibt, aber zu einem regulären ß kleinschreibt. Um alle Unterschiede in der Groß-/Kleinschreibung konsistent zu eliminieren, müssen wir SS, ß und ẞ alle auf dasselbe Ausgabezeichen abbilden. Glücklicherweise bietet uns ICU4X die Case-Folding-Operation, die genau das verspricht. Sehen wir es uns in Aktion an:

let string = "SSßẞ";
println!("string:  {string}");

let locale = icu::locid::locale!("de-DE");

let cm = icu::casemap::CaseMapper::new();
let upper = cm.uppercase_to_string(string, &locale.id);
let lower = cm.lowercase_to_string(string, &locale.id);
let folded = cm.fold_string(string);
println!("upper: {upper}, lower: {lower}, folded: {folded}");
$ cargo run
[...]
string: SSßẞ
upper: SSSSẞ, lower: ssßß, folded: ssssss

Wie wir sehen, wurden im gefalteten String alle verschiedenen Versionen von ß konsistent in ss umgewandelt, was alle Unterschiede in der Groß-/Kleinschreibung erfolgreich eliminiert. Es bedeutet auch, dass ein einzelnes ß als gleichwertig mit einem Kleinbuchstaben ss angesehen würde, was wir sonst vielleicht nicht als gleichwertig betrachtet hätten. Dies ist eine Art von Mehrdeutigkeit, die beim Vergleich von Strings ohne Berücksichtigung der Groß-/Kleinschreibung schwer zu vermeiden ist.

Beachten Sie, dass wir für die Case-Folding-Operation kein Gebietsschema oder keine Sprache angeben mussten. Dies liegt daran, dass Case Folding oft für Bezeichner verwendet wird, die sich unabhängig vom sprachlichen Kontext, in dem sie verwendet werden, identisch verhalten sollen. Die Case-Folding-Operation versucht, Regeln zu verwenden, die in den meisten Sprachen am besten funktionieren. Sie funktionieren jedoch nicht perfekt für türkische Sprachen. Um dies zu beheben, gibt es eine alternative Case-Folding-Operation fold_turkic_string speziell für türkische Sprachen. In den meisten Fällen werden Sie wahrscheinlich die allgemeine Folding-Operation verwenden wollen, es sei denn, Sie sind sich wirklich sicher, dass Sie das spezielle Verhalten für türkische Sprachen benötigen.

Case-insensitiver Vergleich

Angesichts der Case-Folding-Operation könnten wir eine Funktion implementieren, um zwei Strings ohne Berücksichtigung der Groß-/Kleinschreibung wie folgt zu vergleichen:

fn equal_ci(a: &str, b: &str) -> bool {
 let cm = icu::casemap::CaseMapper::new();
 cm.fold_string(a) == cm.fold_string(b)
}

Datenverarbeitung

Bisher haben wir verschiedene Operationen betrachtet, die in einer Vielzahl von Gebietsschemata korrekt über Strings funktionieren, die aus einer riesigen Menge gültiger Codepunkte bestehen. Oberflächlich betrachtet waren diese Operationen relativ einfach zu bedienen, und die meiste Zeit mussten wir nur unsere Eingabe und ein gewünschtes Gebietsschema angeben, um das richtige Ergebnis zu erhalten. Im Hintergrund benötigt ICU4X jedoch viele Daten über verschiedene Gebietsschemata und Unicode-Zeichen, um in jeder Situation das Richtige zu tun. Bisher mussten wir uns jedoch überhaupt nicht um diese Daten kümmern.

Woher bekommt ICU4X all diese Daten? In der Standardkonfiguration, die wir bisher verwendet haben, werden die Daten als Teil der Bibliothek ausgeliefert und direkt in die ausführbare Datei unserer Anwendung kompiliert. Dies hat den Vorteil, dass wir uns keine Sorgen machen müssen, die Daten zusammen mit der Binärdatei auszuliefern und zur Laufzeit darauf zuzugreifen, da die Daten immer in der Binärdatei enthalten sind. Dies geht jedoch auf Kosten von manchmal drastisch erhöhten Binärgrößen. Da standardmäßig Daten für eine große Anzahl von Gebietsschemata enthalten sind, sprechen wir von Dutzenden von Megabyte an Daten, die in der Binärdatei enthalten sind.

Alternativen zu eingebetteten Daten

Da ICU4X so konzipiert ist, dass es selbst in minimalistischen Umgebungen, wie eingebetteten Geräten, läuft, wäre es inakzeptabel, diese erhöhte Anwendungsbinärgröße jeder Anwendung aufzuzwingen. Stattdessen bietet ICU4X mehrere Möglichkeiten, auf die relevanten Daten zuzugreifen. Neben der Verwendung des enthaltenen Standarddatensatzes können Sie auch Ihren eigenen Datensatz mit icu4x-datagen generieren. Dies ermöglicht es Ihnen, die von Anfang an enthaltenen Daten zu reduzieren, entweder durch Begrenzung der Anzahl der einzuschließenden Gebietsschemata oder durch Begrenzung der von den Daten unterstützten Funktionalitäten. Darüber hinaus haben Sie die Wahl, diese Daten direkt in Ihre Anwendungsbinärdatei zu kompilieren oder sie in separate Datendateien zu legen, die Ihre Anwendung dann zur Laufzeit parst.

Die Reduzierung des Satzes verfügbarer Laufzeitdaten hat natürlich den Vorteil, die Datengröße zu reduzieren, die mit Ihrer Anwendung ausgeliefert werden muss. Andererseits hat es den Nachteil, den Satz von Operationen zu reduzieren, die Sie zur Laufzeit erfolgreich ausführen können. Jedes Datenbit, das Sie entfernen, kann dazu führen, dass eine Operation fehlschlägt, wenn keine Daten verfügbar sind, um diese Operation mit dem angeforderten Gebietsschema auszuführen. Wie bei vielen anderen Dingen hat die Reduzierung der Datengröße offensichtliche Vorteile, aber es ist immer ein Kompromiss. In den obigen Beispielen haben wir normalerweise unwrap verwendet, um die Möglichkeit von Fehlern zu ignorieren, aber in einer realen Anwendung werden Sie wahrscheinlich eine ausgefeiltere Fehlerbehandlung wünschen, wie z. B. das Zurückgreifen auf ein nicht fehlerhaftes Verhalten oder zumindest die Meldung des Fehlers an den Benutzer.

Ich werde es vermeiden, alle verfügbaren Optionen im Detail durchzugehen, und stattdessen auf das offizielle Tutorial zum Datenmanagement von ICU4X verweisen. Es sollte alle unterstützten Wege erklären, um die benötigten Daten Ihrer Anwendung zur Verfügung zu stellen.

Fazit

Ich hoffe, dies hat Ihnen einen zufriedenstellenden Überblick darüber gegeben, was ICU4X leisten kann. Wie wir gesehen haben, funktioniert viel Funktionalität problemlos. In anderen Bereichen fehlt es noch an Funktionalität. Zum Beispiel habe ich bereits erwähnt, dass es derzeit keine komfortable Möglichkeit gibt, das bevorzugte Gebietsschema des Benutzers auf standardisierte Weise aus der Ausführungsumgebung zu erkennen. Ein weiterer Bereich, in dem ICU4X derzeit hinter seinen C- und Java-Pendants zurückbleibt, ist die Übersetzungsunterstützung. ICU4X und ICU4J bieten Funktionen zur Formatierung lokalisierter Nachrichten mithilfe von MessageFormats, die ICU4X noch fehlen. Ähnlich scheint ICU4X derzeit keine Funktionalität zu haben, um mit Ressourcenbündeln umzugehen.

Auch wenn ICU4X noch nicht alle Funktionalitäten bietet, die man vielleicht erwarten würde, scheint es insgesamt eine gute Wahl für jene Fälle zu sein, in denen es bereits alle erforderlichen Funktionalitäten mitbringt. Mit etwas mehr Zeit werden wir vielleicht sogar sehen, wie immer mehr der fehlenden Funktionalitäten in ICU4X landen.

Kategorien: credativ® Inside
Tags: i18n ICU ICU4X Internationalisation Internationalization Rust Unicode

SB

über den Autor

Sven Bartscher

Senior Berater

zur Person

Sven Bartscher arbeitet seit 2017 bei credativ im Operations-Solutions-Team und ist dort unter anderem an der Entwicklung und Pflege des Open Security Filters beteiligt. Er arbeitet ebenfalls als Debian-Entwickler an dem freien Betriebssystem Debian.

Beiträge ansehen


Beitrag teilen: