Ruby で Base65536 を実装する

# はじめに

バイナリを文字列に変換する際、[Base64](https://developer.mozilla.org/ja/docs/Glossary/Base64) がよく使用されます。

> Base64 エンコード方式は、ASCII テキスト (または任意のバイナリーデータを受け入れるにはまだ不十分な ASCII のスーパーセット) しか扱えないメディア上で保存や送信を行う際に、バイナリーデータをエンコードするために一般的に使用されます。これにより、転送中にデータが変更されることなく、そのままの状態を確実に保持します。
>
> [Base64 - MDN Web Docs 用語集: ウェブ関連用語の定義 | MDN](https://developer.mozilla.org/ja/docs/Glossary/Base64) より

Base64 では [ASCII](https://ja.wikipedia.org/wiki/ASCII) 文字列に変換しますが、より多くの文字を利用すれば変換後の文字列の長さを短くできるのではと考えました。そこで調べてみると、世の中にはいろいろな Base エンコーディングがあることを知りました。

- 参考: いろいろな Base エンコーディング
  - [Base encoding - この子の名はエビフライとする](https://castor.hatenadiary.org/entry/2021/08/11/085320)

今回は、それらの中でも実装がシンプルな [Base65536](https://github.com/qntm/base65536) を Ruby で実装してみました。

# 実装

Base64 が ASCII 文字列に変換するのに対して、Base65536 は Unicode 文字列に変換します[^1]。変換の際、未割り当てのコードポイントや空白、制御文字などへの変換は避け、安全に使用できるよう工夫されています。

- 参考: Unicode 文字を安全に使用できるのはなぜか
  - [What makes a Unicode code point safe? @ Things Of Interest](https://qntm.org/safe)

今回は以下の Python での実装を Ruby に移植してみました。

- [GitHub - Parkayun/base65536: base65536 Python 2 and 3 implementation.](https://github.com/Parkayun/base65536)

```base65536.rb
module Base65536
  BLOCK_START = {
    0 => 13312,
    1 => 13568, 2 => 13824, 3 => 14080, 4 => 14336, 5 => 14592, 6 => 14848, 7 => 15104, 8 => 15360, 9 => 15616, 10 => 15872, 11 => 16128, 12 => 16384, 13 => 16640, 14 => 16896, 15 => 17152, 16 => 17408, 17 => 17664, 18 => 17920, 19 => 18176, 20 => 18432, 21 => 18688, 22 => 18944, 23 => 19200, 24 => 19456, 25 => 19968, 26 => 20224, 27 => 20480, 28 => 20736, 29 => 20992, 30 => 21248, 31 => 21504, 32 => 21760, 33 => 22016, 34 => 22272, 35 => 22528, 36 => 22784, 37 => 23040, 38 => 23296, 39 => 23552, 40 => 23808, 41 => 24064, 42 => 24320, 43 => 24576, 44 => 24832, 45 => 25088, 46 => 25344, 47 => 25600, 48 => 25856, 49 => 26112, 50 => 26368, 51 => 26624, 52 => 26880, 53 => 27136, 54 => 27392, 55 => 27648, 56 => 27904, 57 => 28160, 58 => 28416, 59 => 28672, 60 => 28928, 61 => 29184, 62 => 29440, 63 => 29696, 64 => 29952, 65 => 30208, 66 => 30464, 67 => 30720, 68 => 30976, 69 => 31232, 70 => 31488, 71 => 31744, 72 => 32000, 73 => 32256, 74 => 32512, 75 => 32768, 76 => 33024, 77 => 33280, 78 => 33536, 79 => 33792, 80 => 34048, 81 => 34304, 82 => 34560, 83 => 34816, 84 => 35072, 85 => 35328, 86 => 35584, 87 => 35840, 88 => 36096, 89 => 36352, 90 => 36608, 91 => 36864, 92 => 37120, 93 => 37376, 94 => 37632, 95 => 37888, 96 => 38144, 97 => 38400, 98 => 38656, 99 => 38912, 100 => 39168,
    101 => 39424, 102 => 39680, 103 => 39936, 104 => 40192, 105 => 40448, 106 => 41216, 107 => 41472, 108 => 41728, 109 => 42240, 110 => 67072, 111 => 73728, 112 => 73984, 113 => 74240, 114 => 77824, 115 => 78080, 116 => 78336, 117 => 78592, 118 => 82944, 119 => 83200, 120 => 92160, 121 => 92416, 122 => 131072, 123 => 131328, 124 => 131584, 125 => 131840, 126 => 132096, 127 => 132352, 128 => 132608, 129 => 132864, 130 => 133120, 131 => 133376, 132 => 133632, 133 => 133888, 134 => 134144, 135 => 134400, 136 => 134656, 137 => 134912, 138 => 135168, 139 => 135424, 140 => 135680, 141 => 135936, 142 => 136192, 143 => 136448, 144 => 136704, 145 => 136960, 146 => 137216, 147 => 137472, 148 => 137728, 149 => 137984, 150 => 138240, 151 => 138496, 152 => 138752, 153 => 139008, 154 => 139264, 155 => 139520, 156 => 139776, 157 => 140032, 158 => 140288, 159 => 140544, 160 => 140800, 161 => 141056, 162 => 141312, 163 => 141568, 164 => 141824, 165 => 142080, 166 => 142336, 167 => 142592, 168 => 142848, 169 => 143104, 170 => 143360, 171 => 143616, 172 => 143872, 173 => 144128, 174 => 144384, 175 => 144640, 176 => 144896, 177 => 145152, 178 => 145408, 179 => 145664, 180 => 145920, 181 => 146176, 182 => 146432, 183 => 146688, 184 => 146944, 185 => 147200, 186 => 147456, 187 => 147712, 188 => 147968, 189 => 148224, 190 => 148480, 191 => 148736, 192 => 148992, 193 => 149248, 194 => 149504, 195 => 149760, 196 => 150016, 197 => 150272, 198 => 150528, 199 => 150784, 200 => 151040,
    201 => 151296, 202 => 151552, 203 => 151808, 204 => 152064, 205 => 152320, 206 => 152576, 207 => 152832, 208 => 153088, 209 => 153344, 210 => 153600, 211 => 153856, 212 => 154112, 213 => 154368, 214 => 154624, 215 => 154880, 216 => 155136, 217 => 155392, 218 => 155648, 219 => 155904, 220 => 156160, 221 => 156416, 222 => 156672, 223 => 156928, 224 => 157184, 225 => 157440, 226 => 157696, 227 => 157952, 228 => 158208, 229 => 158464, 230 => 158720, 231 => 158976, 232 => 159232, 233 => 159488, 234 => 159744, 235 => 160000, 236 => 160256, 237 => 160512, 238 => 160768, 239 => 161024, 240 => 161280, 241 => 161536, 242 => 161792, 243 => 162048, 244 => 162304, 245 => 162560, 246 => 162816, 247 => 163072, 248 => 163328, 249 => 163584, 250 => 163840, 251 => 164096, 252 => 164352, 253 => 164608, 254 => 164864, 255 => 165120,
    -1 => 5376
  }.freeze
  B2 = BLOCK_START.invert.freeze

  class DecodeError < StandardError; end

  class << self
    def encode(str)
      bytes = str.bytes.lazy
      # 2 バイトごとに Unicode コードポイントにマッピングする。
      code_points = bytes.each_slice(2).map { |b1, b2| BLOCK_START[b2 || -1] + b1 }
      chars = code_points.map { _1.chr(Encoding::UTF_8) }

      chars.force.join
    end

    def decode(encoded)
      bytes =
        encoded.each_char.flat_map do |char|
          code_point = char.ord
          b1 = get_lower_8_bits(code_point)
          b2 = B2[code_point - b1]
          raise(DecodeError.new("Invalid Base65536 character: #{char}")) unless b2

          b2 == -1 ? b1 : [b1, b2]
        end

      bytes.pack('C*').force_encoding(Encoding::UTF_8)
    end

    private

    def get_lower_8_bits(n)
      n & 0xFF
    end
  end
end

# この Ruby ファイルを直接実装した際に単体テストを動かす。
# 他の Ruby ファイルで require した際は動かさない。
if __FILE__ == $PROGRAM_NAME
  require 'minitest'
  require 'minitest/autorun'

  class Base65536Test < Minitest::Test
    def test_encode
      assert_equal('驈ꍬ慯𔔠𓁯饬ᔡ', Base65536.encode('Hello, world!'))
    end

    def test_decode
      assert_equal('Hello, world!', Base65536.decode('驈ꍬ慯𔔠𓁯饬ᔡ'))
      error = assert_raises(Base65536::DecodeError) do
        Base65536.decode('驈ꍬ慯𔔠𓁯饬ᔡ🐹')
      end
      assert_equal('Invalid Base65536 character: 🐹', error.message)
    end
  end
end
```

試しに以下の画像[^2]のバイナリをエンコードしてみます。

<img alt="yoyo.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6697/0a20225b-4f20-75d6-2c09-5b1f2153b94c.jpeg" width=400>

```rb
jpg_file = Pathname(Dir.home).join('Downloads/yoyo.jpg')
jpg_file.size
#=> 292315

# バイナリ
binary = jpg_file.binread
# バイナリ → 16 進数文字列
hex_string = binary.unpack1('H*')
# バイナリ → Base64 文字列
base64_string = Base64.strict_encode64(binary)
# バイナリ → Base65536 文字列
base65536_string = Base65536.encode(binary)

# 文字列の文字数
[hex_string.length, base64_string.length, base65536_string.length]
#=> [584630, 389756, 146158]

# 文字列のバイト長
[hex_string.bytesize, base64_string.bytesize, base65536_string.bytesize]
#=> [584630, 389756, 518251]
```

Base65536 でエンコードするとバイト長は大きくなってしまいますが、文字数は減りますね。

# バージョン情報

```rb
RUBY_VERSION
#=> "3.3.6"

Base64::VERSION
#=> "0.2.0"
```

# 参考

- [Base64 - MDN Web Docs 用語集: ウェブ関連用語の定義 | MDN](https://developer.mozilla.org/ja/docs/Glossary/Base64)
- [Base encoding - この子の名はエビフライとする](https://castor.hatenadiary.org/entry/2021/08/11/085320)
- [What makes a Unicode code point safe? @ Things Of Interest](https://qntm.org/safe)
- [qntm/base65536: Unicode's answer to Base64](https://github.com/qntm/base65536)
- [Parkayun/base65536: base65536 Python 2 and 3 implementation.](https://github.com/Parkayun/base65536)

[^1]: もちろん ASCII 文字列と比べると可搬性は落ちます。例えば Unicode を符号化する方式ではない文字コード (e.g. Shift_JIS) だと使用できないなど。
[^2]: [ホースカドライ](https://yoyostorerewind.com/products/houska-dry)というヨーヨーの写真です。可愛いでしょ？🥰