LoginSignup
17
15

More than 5 years have passed since last update.

コードポイントから UTF-8 の文字を生成する

Last updated at Posted at 2014-11-14

バリデーションの際に想定外の文字が通っていないか調べるには Unicode で定義されるすべての文字を試すことが必要です。UTF-8 の場合、コードポイントの範囲は U+0000 から U+7FFF、U+E000 から U+10FFFF までです。

PHP 5 の標準関数である chr は U+0080 よりも大きいコードポイントの範囲には対応しておらず、主要な拡張モジュールにも該当する関数は皆無なので、自分で関数を定義する必要があります。ベンチマークの結果から html_entity_decode や chr を使った方法がもっとよいと考えられます。

ワンライナー

Ruby を利用する場合、次のようなコードになります。

ruby -e 'puts ARGV[0].to_i(16).chr("UTF-8")' 0x3042

複数のコードポイントを受けつけたい場合、少しコードを修正する必要があります。

ruby -e 'ARGV.each {|cp| puts cp.to_i(16).chr("UTF-8")}' 0x3042 0x3044 0x3046 0x3048 0x304A

PHP バージョンもやってみよう。PHP 5.6 の時点で mb_decode_numericentity がもっともコンパクトである。

php -r 'array_shift($argv); echo mb_decode_numericentity("&#".hexdec($argv[0]).";", [0, 0x10FFFF, 0, 0xFFFFFF], "UTF-8"), PHP_EOL;' 0x3042

複数のコードポイントをとるバージョンは次のとおり。

php -r 'array_shift($argv); foreach ($argv as $cp) echo mb_decode_numericentity("&#".hexdec($cp).";", [0, 0x10FFFF, 0, 0xFFFFFF], "UTF-8"), PHP_EOL;' 0x3042 0x3044 0x3046 0x3048 0x304A

mb_chr

PHP 7.2 から mb_chr が利用できるようなる予定です。

IntlChar::chr

PHP 7 から IntlChar::chr を使うことができます。

UTF-32 からの変換

文字コード変換関数の mb_convert_encoding もしくは UConverter::transcode を使います。iconv 関数はあまり使われていないので省略します。これらの関数を使うメリットは実装の間違いが少なくてすむことです。筆者は文字コード変換関数以外の関数を使った実装に間違いがないのかを確認するためにこの方法を選びます。デメリットはパフィーマンスです。すべての文字を4バイトで生成した上で別のバイト列に変換することになるからです。

function utf8_chr($cp) {

    if (!is_int($cp)) {
        exit("$cp is not integer\n");
    }

    if ($cp < 0 || (0xD7FF < $cp && $cp < 0xE000) || 0x10FFFF < $cp) {
        exit("$cp is out of range\n");
    }

    return mb_convert_encoding(pack('N', $cp), 'UTF-8', 'UTF-32BE');
}

function utf8_chr2($cp) {

    if (!is_int($cp)) {
        exit("$cp is not integer\n");
    }

    if ($cp < 0 || (0xD7FF < $cp && $cp < 0xE000) || 0x10FFFF < $cp) {
        exit("$cp is out of range\n");
    }

    return UConverter::transcode(pack('N', $cp), 'UTF8', 'UTF32_BigEndian');
}

HTML の数値文字参照

この方法で使う関数は mb_decode_numericentity もしくは html_entity_decode です。実装のためのコード量が少なく、処理速度が速いので、運用環境で使うとよいでしょう。html_entity_decode を使う場合、U+009F 以下の範囲では文字ではなく数値文字参照をそのまま返すので、この範囲の文字は chr 関数を使って生成します。chr を使ったやり方は後で紹介します。

function utf8_chr3($cp) {

    if (!is_int($cp)) {
        exit("$cp is not integer\n");
    }

    if ($cp < 0 || (0xD7FF < $cp && $cp < 0xE000) || 0x10FFFF < $cp) {
        exit("$cp is out of range\n");
    }

    return mb_decode_numericentity('&#'.$cp.';', [0, 0x10FFFF, 0, 0xFFFFFF], 'UTF-8');
}

function utf8_chr4($cp) {

    if (!is_int($cp)) {
        exit("$cp is not integer\n");
    }

    if ($cp < 0 || (0xD7FF < $cp && $cp < 0xE000) || 0x10FFFF < $cp) {
        exit("$cp is out of range\n");
    }

    if ($cp < 0x80) {
        return chr($cp);
    } else if ($cp < 0xA0) {
        return chr(0xC0 | $cp >> 6).chr(0x80 | $cp & 0x3F);
    }

    return html_entity_decode('&#'.$cp.';');
}

Unicode エスケープシーケンス

json_decode もしくは transliterator_transliterate を使います。どちらの関数も4桁固定長のエスケープシーケンス (\uXXXX)しか扱えないので、U+10000 とそれ以降の範囲は2つのエスケープシーケンス (\uXXXX\uYYYY) を組み合わせることになります。HTML の数値文字参照よりもコードが少し長くなります。

function utf8_chr5($cp) {

    if (!is_int($cp)) {
        exit("$cp is not integer\n");
    }

    if ($cp < 0 || (0xD7FF < $cp && $cp < 0xE000) || 0x10FFFF < $cp) {
        exit("$cp is out of range\n");
    }

    if ($cp < 0x10000) {
        return json_decode('"\u'.bin2hex(pack('n', $cp)).'"');
    }

    // Q: Isn’t there a simpler way to do this?
    // http://unicode.org/faq/utf_bom.html#utf16-4
    $lead = 0xD800 - (0x10000 >> 10) + ($cp >> 10);
    $trail = 0xDC00 + ($cp & 0x3FF);

    return json_decode('"\u'.bin2hex(pack('n', $lead)).'\u'.bin2hex(pack('n', $trail)).'"');
}

function utf8_chr6($cp) {

    if (!is_int($cp)) {
        exit("$cp is not integer\n");
    }

    if ($cp < 0 || (0xD7FF < $cp && $cp < 0xE000) || 0x10FFFF < $cp) {
        exit("$cp is out of range\n");
    }

    if ($cp < 0x10000) {
        $hex = dechex($cp);
        $hex = str_repeat('0', 4 - strlen($hex)).$hex;

        return transliterator_transliterate('Hex-Any/Java', '\u'.$hex);
    }

    $high = 0xD800 | $cp - 0x10000 >> 10;
    $low = 0xDC00 | $cp - 0x10000 & 0x3FF;

    return transliterator_transliterate('Hex-Any/Java', 
        '\u'.dechex($high).'\u'.dechex($low));
}

ビット演算と chr

C/C++ 言語で UTF-8 の文字列関数を自分で定義する場合、ビット演算は必須です。フレームワークやライブラリのテストケースで採用されています。Unicode Standard の3章にビットの分布が記載されています (テーブル 3-6)。

スカラー値 第1バイト 第2バイト 第3バイト 第4バイト
00000000 0xxxxxxx 0xxxxxxx
00000yyy yyxxxxxx 110yyyyy 10xxxxxx
zzzzyyyy yyxxxxxx 1110zzzz 10yyyyyy 10xxxxxx
000uuuuu zzzzyyyy yyxxxxxx 11110uuu 10uuzzzz 10yyyyyy 10xxxxxx
function utf8_chr7($cp) {

    if (!is_int($cp)) {
        exit("$cp is not integer\n");
    }

    if ($cp < 0 || (0xD7FF < $cp && $cp < 0xE000) || 0x10FFFF < $cp) {
        exit("$cp is out of range\n");
    }

    if ($cp < 0x80) {

        return chr($cp);

    } else if ($cp < 0x800) {

        return chr(0xC0 | $cp >> 6)
              .chr(0x80  | $cp & 0x3F);

    } else if ($cp < 0x10000) {

        return chr(0xE0 | $cp >> 12)
               .chr(0x80 | $cp >> 6 & 0x3F)
               .chr(0x80 | $cp & 0x3F);       

    }

    return chr(0xF0 | $cp >> 18)
          .chr(0x80 | $cp >> 12 & 0x3F)
          .chr(0x80 | $cp >> 6 & 0x3F)
          .chr(0x80 | $cp & 0x3F);
}

ビット演算の計算過程

計算過程をわかりやすいようにするために、ビット演算に使う数値を2進数に書き換えたものは次のようになります。

var_dump(
    '$' === utf8_chr8(0x0024),
    '¢' === utf8_chr8(0x00A2),
    '€' === utf8_chr8(0x20AC),
    '?' === utf8_chr8(0x10348)
);

function utf8_chr8($cp) {

    if (!is_int($cp)) {
        exit("$cp is not integer\n");
    }

    if ($cp < 0 || (0xD7FF < $cp && $cp < 0xE000) || 0x10FFFF < $cp) {
        exit("$cp is out of range\n");
    }

    if ($cp < 0x80) {

        return chr($cp);

    } else if ($cp < 0x800) {

        return chr(0b11000000 + ($cp >> 6))
              .chr(0b10000000 + ($cp & 0b111111));

    } else if ($cp < 0x10000) {

        return chr(0b11100000 + ($cp >> 12))
              .chr(0b10000000 + ($cp >> 6 & 0b111111))
              .chr(0b10000000 + ($cp & 0b111111));       

    }

    return chr(0b11110000 + ($cp >> 18))
          .chr(0b10000000 + ($cp >> 12 & 0b111111))
          .chr(0b10000000 + ($cp >> 6 & 0b111111))
          .chr(0b10000000 + ($cp & 0b111111));
}

Wikipedia の UTF-8 の記事には1バイト文字から4バイト文字までの対応表が記載されている。

UTF-8 の例

先ほど実装した関数がバイト列の計算結果が想定どおりになっているか4バイト文字の U+10348 で試してみよう。

$cp = 0x10348;
$char = "\xF0\x90\x8D\x88";

var_dump(
    '10000001101001000' === decbin($cp),
    [
        '11110000' === decbin(ord($char[0])),
        '10010000' === decbin(ord($char[1])),
        '10001101' === decbin(ord($char[2])),
        '10001000' === decbin(ord($char[3]))
    ],
    '11110000' === decbin(0b11110000 + ($cp >> 18)),
    '10010000' === decbin(0b10000000 + ($cp >> 12 & 0b111111)),
    '10001101' === decbin(0b10000000 + ($cp >> 6 & 0b111111)),
    '10001000' === decbin(0b10000000 + ($cp & 0b111111))
);

テスト

同じコードポイントから同じ文字が生成されるか比較します。

test('utf8_chr', 'utf8_chr2');

function test($callable, $callable2) {

    for ($i = 0; $i < 0x110000; ++$i) {

        if ($i > 0xD7FF && $i < 0xE000) {
            continue;
        }

        $char = $callable($i);
        $char2 = $callable2($i);
        $hex = strtoupper(dechex($i));

        if ($char !== $char2) {
            echo 'U+',
            $i < 0x1000 ? str_repeat('0', 4 - strlen($hex)) : '',
            $hex, PHP_EOL;
        }

    }
}

ベンチマーク

筆者のマシンでは html_entity_decode を使ったコードが最速で、2位が chr でした。transliterator_transliterate は一桁遅いので、使わないほうがよいでしょう。

array (
  'html_entity_decode' => 0.12990903854370117,
  'chr' => 0.16111207008361816,
  'json_decode' => 0.23690009117126465,
  'mb_decode_numericentity' => 0.2619929313659668,
  'mb_convert_encoding' => 0.42672300338745117,
  'UConverter::transcode' => 0.51613211631774902,
  'transliterator_transliterate' => 6.8395729064941406,
)
$ret = [
  'mb_convert_encoding' => timer(function() {
    utf8_chr(0x10FFFF);
  }),
  'UConverter::transcode' => timer(function() {
    utf8_chr2(0x10FFFF);
  }),
  'mb_decode_numericentity' => timer(function() {
    utf8_chr3(0x10FFFF);
  }),
  'html_entity_decode' => timer(function() {
    utf8_chr4(0x10FFFF);
  }),
  'json_decode' => timer(function() {
    utf8_chr5(0x10FFFF);
  }),
  'transliterator_transliterate' => timer(function() {
    utf8_chr6(0x10FFFF);
  }),
  'chr' => timer(function() {
    utf8_chr7(0x10FFFF);
  })
];

asort($ret);
var_export($ret);

function timer($callable, $repeat = 100000) {

    if (!is_int($repeat)) {
        exit("$repeat is not integer");
    }

    if ($repeat < 0) {
        exit("$repeat is not positive integer");
    }

    $start = microtime(true);

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

    $stop = microtime(true);

    return  $stop - $start;
}
17
15
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
17
15