😎

【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 正規化の処理に巻き込まないようにするためには書記素クラスター単位の取り出しや正規表現による文字の種類の識別が必要になる

<?php
$str = "は\u{309A}は\u{309A}";

var_dump(
  2 === grapheme_strlen($str),
  "は\u{309A}" === grapheme_substr($str, 0, 1)
);
<?php

var_dump(
  (bool) preg_match('/\p{Hiragana}/u', 'はは'),
  (bool) preg_match('/\p{Katakana}/u', 'ハハ'),
  mb_ereg_match('\p{Hiragana}', 'はは'),
  mb_ereg_match('\p{Katakana}', 'ハハ')
);

処理をまとめて関数を定義する

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

function convert_kana_to_nfc(string $str): string {

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

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

    if (preg_match('/[\p{Hiragana}\p{Katakana}][\x{3099}\x{309A}]/u', $g)) {
      $g = normalizer_normalize($g);
    }

    $ret .= $g;
  }

  return $ret;
}

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

<?php

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

function convert_kana_to_nfc2(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) { convert_kana_to_nfc($str); },
  'str_replace' => function() use ($str) { convert_kana_to_nfc2($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