Rubyだけで文字コード変換を実装する

Ruby Advent Calendar 2021 - Qiita の5日目の記事です。

こんにちは。ima1zumiです。 私はRubyKaigi Takeout 2021 で Dive into Encoding というタイトルでオレオレ文字コードを作って文字コードを学ぶ話をしました。 その中で、C拡張のgemとして自作文字コードEncoding::IROHARubyで使えるようにしました。

それがこちらです。

https://github.com/ima1zumi/encoding_iroha/

このgemを使うことで、みなさんのお手元でもEncoding::IROHAという文字コードが使えるようになります。 どう作るかとかそういった話はRubyKaigi Takeout 2021のアーカイブをご覧ください。

ですが思ったのです。C拡張のgemってビルド要るからめんどくさいですよね。

ということで、pure Ruby文字コード変換を(雑に)実装してみました。それがこちらです。

https://github.com/ima1zumi/ruby_encoding_iroha/

今日はこのコードについて解説します。これを読み終わるころには、みなさんもRuby文字コード変換が実装できるようになっているはずです。*1

文字コード変換を実装するとは

RubyKaigi で語ったので割愛しますが、Rubyの場合はEncodingクラスの定数として使えるようにすることと変換器が必要です。

Encodingクラスの定数

Encoding には、 replicate というメソッドがあります。これを使うと元となった encoding と同じバイト構造を持つ Encoding クラスの定数を作ることができます。何をやっているか知りたい場合は CRuby の rb_enc_replicate を見るとよいです。

Encoding::IROHA は1バイト固定長の文字コードなので、同じく1バイト固定長の ASCII_8BIT を元に IROHA を作りましょう。

Encoding::ASCII_8BIT.replicate("IROHA")

これで Encoding::IROHA という定数を参照できるようになります。変換器はまだ実装していないので、いろは歌への変換はできません。

replicate の用途に興味があるので、ご存知の方がいればTwitter等で教えてもらえると嬉しいです。

変換器

Encoding::IROHAいろは歌+ASCIIをつかえるようにした文字コードです。RubyCSI方式で多言語対応されているので、何に変換できるようにしてあげるか決めてあげないといけません。とりあえずUnicodeに変換できるようにしましょう。UTF-8を基本としつつ、せっかくなのでUnicode系全般に対応させてみました。

    UNICODE =
    [
      Encoding::UTF_16,
      Encoding::UTF_16BE,
      Encoding::UTF_16LE,
      Encoding::UTF_32,
      Encoding::UTF_32BE,
      Encoding::UTF_32LE,
      Encoding::UTF_7,
      Encoding::UTF_8,
      Encoding::UTF8_DOCOMO,
      Encoding::UTF8_KDDI,
      Encoding::UTF8_MAC,
      Encoding::UTF8_SOFTBANK,
    ]

るりまを見てUTFと入っているものからチョイスしたので漏れや、もはや使わなさそうな文字コードもあるかもしれませんが、大体良さそうです。

さて変換テーブルを実装しましょう。いろは歌部分は50文字もないので、変換テーブルはhashでちゃちゃっと書いてしまいまします。

    UTF8_TO_IROHA_TABLE = {
      "" => "\x80",
      "" => "\x81",
      "" => "\x82",
      "" => "\x83",
      "" => "\x84",
      "" => "\x85",
      "" => "\x86",
      "" => "\x87",
      "" => "\x88",
      "" => "\x89",
      "" => "\x8A",
      "" => "\x8B",
      "" => "\x8C",
      "" => "\x8D",
      "" => "\x8E",
      "" => "\x8F",
      "" => "\x90",
      "" => "\x91",
      "" => "\x92",
      "" => "\x93",
      "" => "\x94",
      "" => "\x95",
      "" => "\x96",
      "" => "\x97",
      "" => "\x98",
      "" => "\x99",
      "" => "\x9A",
      "" => "\x9B",
      "" => "\x9C",
      "" => "\x9D",
      "" => "\x9E",
      "" => "\x9F",
      "" => "\xA0",
      "" => "\xA1",
      "" => "\xA2",
      "" => "\xA3",
      "" => "\xA4",
      "" => "\xA5",
      "" => "\xA6",
      "" => "\xA7",
      "" => "\xA8",
      "" => "\xA9",
      "" => "\xAA",
      "" => "\xAB",
      "" => "\xAC",
      "" => "\xAD",
      "" => "\xAE",
    }

なんで\x80なのかとかはRubyKaigiのアーカイブを見てください。

変換テーブルができたので、UnicodeからIROHAへの変換を実装していきましょう。

UTF8_TO_IROHA_TABLEいろは歌部分とのマッチングをします。次にASCIIを変換しますが、ASCIIの場合はバイト列はUTF-8と同じなので ascii_only? メソッドでASCIIであるかどうかだけ確認してあげます。いろは歌でもASCIIでもない文字…例えばひらがなの "が" は変換不能な文字です。ここでは Encoding::UndefinedConversionError を上げて止めるようにしました。

    def utf8_to_iroha(char)
      iroha = UTF8_TO_IROHA_TABLE[char]

      if iroha
        iroha
      else iroha.nil?
        if char.ascii_only?
          char
        else
          raise Encoding::UndefinedConversionError
        end
      end
    end

変換不能文字の処理にバグがあってうまくいかないパターンがありますが、とりあえずこのままいきましょう。*2

文字を1文字ずつ変換して、最後に連結します。join すると encoding が IROHA ではないので、 force_encoding で Encoding::IROHA に置き換えます。

ちなみに force_encoding は、その Encoding で正しいバイト列かどうかチェックしないので注意してください。Ruby では encoding は String についているラベルのようなものなのですが、ラベルだけ張り替えて変換処理を行わないのが force_encoding です。encode はラベルの張替えだけでなく実際の変換処理を行いバイト列を書き換えます。 ここでは変換処理は utf8_to_iroha で実行しているため、最後に encoding 情報を正しくするという目的で force_encoding しています。

    def to_iroha(encoding)
      raise Encoding::ConverterNotFoundError unless UNICODE.include?(encoding)

      self.each_char.map { |char| utf8_to_iroha(char.encode(Encoding::UTF_8)) }.join.force_encoding(Encoding::IROHA)
    end

同様にIROHAからUnicodeへの変換処理を実装します。

    IROHA_TO_UTF8_TABLE = UTF8_TO_IROHA_TABLE.to_h { |utf8, iroha|
      [iroha.b, utf8]
    }


    def iroha_to_utf8(char)
      utf8 = IROHA_TO_UTF8_TABLE[char.b]

      if utf8
        utf8
      else utf8.nil?
        if char.ascii_only?
          char
        else
          raise Encoding::UndefinedConversionError
        end
      end
    end

.b しているのは encoding が同じでないとうまく比較処理が動かなかったためです。なんでだろう…

後はおおむね IROHA への変換と同じです。違うのは最後の encode(encoding) で、ここでは Unicode 系への変換処理を行うため Ruby 本体の変換器を使えるので、force_encoding ではなく encode しています。

    def from_iroha(encoding)
      raise Encoding::ConverterNotFoundError unless UNICODE.include?(encoding)

      self.each_char.map { |char| iroha_to_utf8(char) }.join.encode(encoding)
    end

String#encode に IROHA 用の変換処理を挟み込みます。 from_encoding の扱いがこれでいいのかはよくわかってないです。また、**options には対応していません。

    def encode(encoding, from_encoding = self.encoding, **options)

      to_enc = convert_encoding_constant(encoding)
      from_enc = convert_encoding_constant(from_encoding)

      if to_enc == Encoding::IROHA
        return to_iroha(from_enc)
      elsif from_encoding == Encoding::IROHA
        return from_iroha(to_enc)
      end

      super
    end

これでできました。 雑にStringに追加しているのでお行儀は悪いのですが、とりあえず動くようになったのでヨシとしましょう。

感想

C拡張でしか実現出来ないと思っていたRubyへの文字コードの実装が、Rubyだけで出来て気持ちが良かったです

*1:本当に文字コードの追加が必要な場合は、自作せずに https://bugs.ruby-lang.org/ で相談しましょう

*2: 'a'.encode(Encoding::UTF_16).encode(Encoding::IROHA)すると空文字列が返ってくる。多分BOMの扱いが間違っていてバグっている。