😎

【PHP】NFD の濁音・半濁音を NFC に変換する

2024/06/12に公開

NFD 形式では濁音・半濁音は基底文字と結合文字の組み合わせで表現される。結合文字として使われる濁点、半濁点はそれぞれ U+3099、U+309A である

<?php

echo "は\u{3099}", PHP_EOL;
echo "は\u{309A}", PHP_EOL;

macOS では Finder で日本語名ファイルを生成すると NFD になる。

2017年に macOS に APFS (Apple File System) が導入される以前では Apple 独自の NFD (通称 Apple modified NFD) が macOS のファイル名に採用されていた。

NFD 形式の文字列を NFC 形式に変換するには normalizer_normalize を使う

<?php

var_dump(
  "ば" === normalizer_normalize("は\u{3099}"),
  "ぱ" === normalizer_normalize("は\u{309A}")
);

normalizer_normalize を使う上で注意が必要なのは旧字体の漢字などの無関係な文字まで変換しないようにすることである。変換を避けるべき文字は「合成除外文字」と呼ばれる

<?php

var_dump(
  "神" === normalizer_normalize("神", Normalizer::FORM_C),
  "神" === normalizer_normalize("神", Normalizer::FORM_D)
);

合成除外文字を Unicode 正規化の処理に巻き込まないようにするためには正規表現か書記素クラスターを扱える API を使って絞り込みが必要になる。

まずは正規表現で取り組んでみる。ひらがな、カタカナと濁点、半濁音のまとまりを Unicode プロパティであらわすと [\p{Hiragana}\p{Katakana}][\x{3099}\x{309A}] で表現できる。濁音・半濁音のコードポイントをずっと覚えるのは困難なので Mn プロパティ(Non-spacing mark、他の文字を修飾するための文字)に置き換えて [\p{Hiragana}\p{Katakana}]\p{Mn} と表現することができる。

よって preg_replace_callbackmb_ereg_replace_callback を使って次のように書くことができる。

$str = "は\u{3099}は\u{3099}とハ\u{309A}ハ\u{309A}と神";

$ret = 'ばばとパパと神';
var_dump(
    $ret === dakuon_normalize($str),
    $ret === dakuon_normalize2($str)
);

function dakuon_normalize(string $str): string {
    return preg_replace_callback(
        '/[\p{Hiragana}\p{Katakana}]\p{Mn}/su',
        fn($matches) => normalizer_normalize($matches[0]),
        $str);
}

function dakuon_normalize2(string $str): string {
     return mb_ereg_replace_callback(
         '[\p{Hiragana}\p{Katakana}]\p{Mn}',
         fn($matches) => normalizer_normalize($matches[0]),
         $str);
}

次に書記素クラスターを取り出す場合に取り組んでみる。書記素クラスターを取り出したあと、2つのコードポイントで構成され、かつ2つめのコードポイントが 0x3099 もしくは 0x309A である場合に Unicode 正規化を適用する

$str = "は\u{3099}は\u{3099}とハ\u{309A}ハ\u{309A}と神";
$ret = 'ばばとパパと神';
var_dump(
    $ret === dakuon_normalize3($str)
);

function dakuon_normalize3(string $str): string {

    $size = grapheme_strlen($str);
    $ret = '';

    for ($i = 0; $i < $size; ++$i) {
        $g = grapheme_substr($str, $i, 1);

        if (2 === mb_strlen($g)) {
            $cp = mb_ord(mb_substr($g, 1, 1));

            if ($cp == 0x3099 || $cp == 0x309A) {
                $g = normalizer_normalize($g);
            }
        }

        $ret .= $g;
    }

    return $ret;
}

別の手法は str_replace でシンプルに置き換える方法である

<?php

$str = "ハ\u{309A}ハ\u{309A}と神";
var_dump("パパと神" === dakuon_normalize4($str));

function dakuon_normalize4(string $str): string {
  $before = [
    'が', 'ぎ', 'ぐ', 'げ', 'ご',
    'ざ','じ', 'ず', 'ぜ', 'ぞ',
    'だ', 'ぢ', 'づ', 'で', 'ど',
    'ば', 'び', 'ぶ', 'べ', 'ぼ',
    'ぱ', 'ぴ', 'ぷ', 'ぺ', 'ぽ',
    'ガ', 'ギ', 'グ', 'ゲ', 'ゴ',
    'ザ', 'ジ', 'ズ', 'ゼ', 'ゾ',
    'ダ', 'ヂ', 'ヅ', 'デ', 'ド',
    'バ', 'ビ', 'ブ', 'ベ', 'ボ',
    'パ', 'ピ', 'プ', 'ペ', 'ポ',
    'ヴ'
  ];

  $after = [
    'が', 'ぎ', 'ぐ', 'げ', 'ご',
    'ざ', 'じ', 'ず', 'ぜ', 'ぞ',
    'だ', 'ぢ', 'づ', 'で', 'ど',
    'ば', 'び', 'ぶ', 'べ', 'ぼ',
    'ぱ', 'ぴ', 'ぷ', 'ぺ', 'ぽ',
    'ガ', 'ギ', 'グ', 'ゲ', 'ゴ',
    'ザ', 'ジ', 'ズ', 'ゼ', 'ゾ',
    'ダ', 'ヂ', 'ヅ', 'デ', 'ド',
    'バ', 'ビ', 'ブ', 'ベ', 'ボ',
    'パ', 'ピ', 'プ', 'ペ', 'ポ',
    'ヴ'
  ];

  return str_replace($before, $after, $str);
}

ベンチマークをとると str_replace のほうが早い

php test.php

array(2) {
  ["normalizer"]=>
  int(523097868)
  ["str_replace"]=>
  int(199818621)
}
test.php
$str = "ハ\u{309A}ハ\u{309A}と神";

var_dump(benchmark([
  'normalizer' => function() use ($str) { dakuon_normalize3($str); },
  'str_replace' => function() use ($str) { dakuon_normalize4($str); }
]));

function benchmark(array $callables, int $repeat = 100000): array {

    $ret = [];
    $save = $repeat;

    foreach ($callables as $key => $callable) {

        $start = hrtime(true);

        do {
            $callable();
        } while($repeat -= 1);

        $stop = hrtime(true);
        $ret[$key] = $stop - $start;
        $repeat = $save;
    }

    return $ret;
}

Discussion