7 posts 31 tags 7 domains

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 kodowea (U+0061) + ogonek U+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 MnMark, Nonspacing).

text
ą  →  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

FormaCo robi
NFCDekompozycja + złożenie z powrotem — najczęstsza w sieci i plikach
NFDTylko dekompozycja — rozłożenie na składniki
NFKCKompatybilna dekompozycja + złożenie (normalizuje też ligaturę fi)
NFKDKompatybilna 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
<?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

php
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.

php
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

php
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

php
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

js
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

js
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

js
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

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
js
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:

  1. Canonical Decomposition — każdy prekomponowany znak jest zastępowany przez sekwencję kanonicznych składników zgodnie z tablicą UnicodeData.txt
  2. 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

ZadaniePHPJS
Normalizacja do NFCNormalizer::normalize($s, Normalizer::NFC)s.normalize("NFC")
Normalizacja do NFDNormalizer::normalize($s, Normalizer::NFD)s.normalize("NFD")
Usunięcie diakrytykówNFD + preg_replace('/\p{Mn}/u', '', $s)NFD + .replace(/\p{Mn}/gu, "")
Sprawdzenie formyNormalizer::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.