Normalizacja NFD w PHP i JS — praktyczne zastosowania
Dowiesz się, czym jest normalizacja Unicode NFD, dlaczego psuje porównania tekstów i jak ją obsłużyć w PHP i JavaScript.
Problem
Masz dwa ciągi znaków, które wyglądają identycznie — np. "zażółć" — ale porównanie === zwraca false. Albo usuwasz diakrytyki regexem i część liter z ogonkami przepada, a część nie. Albo plik zapisany na macOS nie otwiera się poprawnie na Linuksie, bo nazwa zawiera polskie znaki.
Winowajcą jest najczęściej normalizacja Unicode — a konkretnie jej brak albo niezgodność między NFD a NFC.
Co to jest NFD?
Unicode pozwala zapisać ten sam widoczny znak na kilka sposobów:
- Jako jeden punkt kodowy — np.
ą=U+0105(prekomponowany) - Jako dwa punkty kodowe —
a(U+0061) + ogonekU+0328(bazowa litera + znak diakrytyczny)
NFD (Canonical Decomposition) to forma normalizacji, która rozkłada prekomponowane znaki na składniki: literę bazową + oddzielny znak łączący (kategoria Unicode Mn — Mark, Nonspacing).
ą → a + ̨ (U+0061 + U+0328)
é → e + ́ (U+0065 + U+0301)
ś → s + ́ (U+0073 + U+0301)
ń → n + ́ (U+006E + U+0301)
Wizualnie nic się nie zmienia. Ale w pamięci "ą" to teraz 2 znaki zamiast 1.
Cztery formy normalizacji Unicode
| Forma | Co robi |
|---|---|
| NFC | Dekompozycja + złożenie z powrotem — najczęstsza w sieci i plikach |
| NFD | Tylko dekompozycja — rozłożenie na składniki |
| NFKC | Kompatybilna dekompozycja + złożenie (normalizuje też ligaturę fi → fi) |
| NFKD | Kompatybilna dekompozycja |
NFD w PHP
PHP obsługuje normalizację Unicode przez rozszerzenie intl (domyślnie dostępne w większości instalacji).
Sprawdzenie i normalizacja
<?php
use Normalizer;
$nfc = "zażółć"; // typowy ciąg — forma NFC
$nfd = Normalizer::normalize($nfc, Normalizer::NFD);
echo mb_strlen($nfc); // 6
echo mb_strlen($nfd); // 11 — każda litera z diakrytykiem = 2 znaki
var_dump(Normalizer::isNormalized($nfc, Normalizer::NFC)); // bool(true)
var_dump(Normalizer::isNormalized($nfc, Normalizer::NFD)); // bool(false)
Bezpieczne porównywanie tekstów
function stringsEqual(string $a, string $b): bool
{
$a = Normalizer::normalize($a, Normalizer::NFC);
$b = Normalizer::normalize($b, Normalizer::NFC);
return $a === $b;
}
$s1 = "café"; // NFC — é jako jeden znak
$s2 = "cafe\u{0301}"; // NFD — e + akcent
var_dump($s1 === $s2); // bool(false) — pułapka!
var_dump(stringsEqual($s1, $s2)); // bool(true) — poprawnie
Usuwanie diakrytyków (transliteracja)
Najczystszy sposób: NFD → usuń znaki Mn → zostają same litery bazowe.
function stripDiacritics(string $str): string
{
$nfd = Normalizer::normalize($str, Normalizer::NFD);
return preg_replace('/\p{Mn}/u', '', $nfd);
}
echo stripDiacritics("zażółć gęślą jaźń");
// zazolc gesla jazn
Generowanie slugów URL
function slugify(string $str): string
{
$str = mb_strtolower($str, 'UTF-8');
$str = Normalizer::normalize($str, Normalizer::NFD);
$str = preg_replace('/\p{Mn}/u', '', $str);
$str = preg_replace('/[^a-z0-9]+/', '-', $str);
return trim($str, '-');
}
echo slugify("Zażółć Gęślą Jaźń!");
// zazolc-gesla-jazn
Normalizacja danych wejściowych w formularzach
function normalizeInput(string $input): string
{
// Zawsze sprowadzaj do NFC przed zapisem do bazy
return Normalizer::normalize(trim($input), Normalizer::NFC);
}
NFD w JavaScript
JavaScript ma wbudowaną obsługę normalizacji Unicode od ES6 — metoda String.prototype.normalize().
Podstawowe użycie
const nfc = "zażółć";
const nfd = nfc.normalize("NFD");
console.log(nfc.length); // 6
console.log(nfd.length); // 11
console.log(nfc === nfd); // false — choć wyglądają tak samo
Bezpieczne porównywanie
function stringsEqual(a, b) {
return a.normalize("NFC") === b.normalize("NFC");
}
const s1 = "caf\u00E9"; // NFC — é jako jeden znak
const s2 = "cafe\u0301"; // NFD — e + akcent
console.log(s1 === s2); // false
console.log(stringsEqual(s1, s2)); // true
Usuwanie diakrytyków
function stripDiacritics(str) {
return str
.normalize("NFD")
.replace(/\p{Mn}/gu, ""); // flaga u + \p{Mn} — Unicode property escapes (ES2018+)
}
console.log(stripDiacritics("zażółć gęślą jaźń"));
// zazolc gesla jazn
Slugify w JS
function slugify(str) {
return str
.toLowerCase()
.normalize("NFD")
.replace(/\p{Mn}/gu, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
}
console.log(slugify("Zażółć Gęślą Jaźń!"));
// zazolc-gesla-jazn
Wyszukiwanie bez diakrytyków (live search)
function searchNormalize(str) {
return str.normalize("NFD").replace(/\p{Mn}/gu, "").toLowerCase();
}
const items = ["Zażółć", "Gęślą", "Jaźń", "Cafe", "Résumé"];
function search(query, items) {
const q = searchNormalize(query);
return items.filter(item => searchNormalize(item).includes(q));
}
console.log(search("gesla", items)); // ["Gęślą"]
console.log(search("cafe", items)); // ["Cafe", "Café"] — gdyby było w liście
Co się dzieje pod spodem
Algorytm NFD działa w dwóch krokach:
- Canonical Decomposition — każdy prekomponowany znak jest zastępowany przez sekwencję kanonicznych składników zgodnie z tablicą
UnicodeData.txt - Canonical Ordering — znaki łączące są sortowane według wartości
Canonical Combining Class(CCC), żeby sekwencje złożone z tych samych elementów ale w innej kolejności były równoważne
NFC robi to samo, ale po dekompozycji dodaje krok Canonical Composition — składa z powrotem te znaki, które mają prekomponowaną formę.
Podsumowanie
| Zadanie | PHP | JS |
|---|---|---|
| Normalizacja do NFC | Normalizer::normalize($s, Normalizer::NFC) | s.normalize("NFC") |
| Normalizacja do NFD | Normalizer::normalize($s, Normalizer::NFD) | s.normalize("NFD") |
| Usunięcie diakrytyków | NFD + preg_replace('/\p{Mn}/u', '', $s) | NFD + .replace(/\p{Mn}/gu, "") |
| Sprawdzenie formy | Normalizer::isNormalized($s, Normalizer::NFC) | brak natywnej metody |
Zasada ogólna: zawsze normalizuj do NFC dane wejściowe przed zapisem do bazy lub porównaniem. NFD stosuj tymczasowo — jako krok pośredni do operacji na diakrytykach.