文字列を特定のバイト数 (not 文字数) で切り詰める

# やりたいこと

文字列を特定のバイト数 (not 文字数) で切り詰めたいです。

# 最初に結論

Active Support コア拡張機能が提供する [String#truncate_bytes](https://railsguides.jp/active_support_core_extensions.html#truncate-bytes
) を使います。

```rb
limit = 100 # bytes

engineers = '👨🏿‍💻👩🏿‍💻👩🏼‍💻👨🏻‍💻👩🏾‍💻👨‍💻👨🏼‍💻👨🏾‍💻👨🏽‍💻👩‍💻👩🏻‍💻👩🏽‍💻'
engineers.bytesize
#=> 172

truncated_engineers = engineers.truncate_bytes(limit, omission: '')
#=> "👨🏿‍💻👩🏿‍💻👩🏼‍💻👨🏻‍💻👩🏾‍💻👨‍💻"
truncated_engineers.bytesize
#=> 86
```

# 長い解説

## 例: 文字列を MySQL の TEXT 型に収まる範囲に切り詰めたい

とある Rails アプリケーションでデータベースに MySQL を使っているとします。

yoyos というテーブルがあります。

```sql
CREATE TABLE `yoyos` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `brand_id` bigint NOT NULL,
  `name` varchar(255) NOT NULL,
  `diameter` float NOT NULL,
  `width` float NOT NULL,
  `weight` float NOT NULL,
  `description` text,
  `created_at` datetime(6) NOT NULL,
  `updated_at` datetime(6) NOT NULL,
  PRIMARY KEY (`id`),
  KEY `index_yoyos_on_brand_id` (`brand_id`),
  CONSTRAINT `fk_rails_e9be80a518` FOREIGN KEY (`brand_id`) REFERENCES `brands` (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
```

yoyos テーブルは TEXT 型の description カラムを持ちます。この description カラムに 16,384 文字の文字列を設定して保存しようとすると `ActiveRecord::ValueTooLong` が発生してしまいます。

```rb
yoyo = Yoyo.find_by!(name: 'ヘトロジェニティ')
yoyo.update!(description: '👼' * 16_383)
#=> true
yoyo.update!(description: '👼' * 16_384)
# Mysql2::Error:
# Data too long for column 'description' at row 1 (ActiveRecord::ValueTooLong)
```

これは MySQL の TEXT 型の制限が 65,535 バイトまでだからです。


> A TEXT column with a maximum length of 65,535 (2^16 − 1) characters.
The effective maximum length is less if the value contains multibyte characters.
Each TEXT value is stored using a 2-byte length prefix that indicates the number of bytes in the value.
> (https://dev.mysql.com/doc/refman/8.4/en/string-type-syntax.html より)

```rb
column = Yoyo.columns_hash['description']
column.sql_type_metadata.then { [_1.type, _1.limit] }
#=> [:text, 65535]

('👼' * 16_383).bytesize
#=> 65532
('👼' * 16_384).bytesize
#=> 65536
```

そこで文字列を 65,535 バイトで切り詰めたいです。しかし UTF-8 では 1 文字ごとのバイト数が異なるので、単純に文字数で切り詰めることはできません。

```rb
# とても長い文字列を 65,535 文字に切り詰めても 262,131 バイトで、65,535 バイトを超えてしまう。
too_many_angels = '👼' * 100_000
too_many_angels.truncate(65_535, ommision: '').bytesize
#=> 262131

# 16,386 文字に切り詰めるとちょうどいい。
too_many_angels.truncate(16_386, ommision: '').bytesize
#=> 65535

# しかし、仮に 1 バイト文字のみで構成される文字列の場合は
# 16,386 文字に切り詰めるとたくさんの無駄が生じてしまう。
too_many_alphabets = [*'a'..'z', *'A'..'Z'].then { |chars| Array.new(100_000) { chars.sample }.join }
too_many_alphabets.truncate(16_386, ommision: '').bytesize
#=> 16386
```

そこで [String#bytes](https://docs.ruby-lang.org/ja/latest/method/String/i/bytes.html) で文字列をいったんバイト数値 (整数) の配列に変換し、[Array#slice](https://docs.ruby-lang.org/ja/latest/method/Array/i/slice.html) で 65,535 バイトに切り詰めます。

```rb
# バイト数値 (整数) の配列に変換して、要素を 65,535 個に切り詰める。
truncated_bytes = too_many_angels.bytes.slice(...65_535)
# バイト数値 (整数) の配列を文字列に戻す。
truncated_many_angels = truncated_bytes.pack('c*').force_encoding('UTF-8')

truncated_many_angels.bytesize
#=> 65535
```

しかし、切り詰めた文字列の末尾を確認すると文字が壊れています 😢

```rb
truncated_many_angels.last(10)
#=> "👼👼👼👼👼👼👼\xF0\x9F\x91"
```

これは文字の途中のバイトで切り詰めたためです。

```rb
# 切り詰めた際に末尾の \xBC が消されてしまった。
'👼'.bytes.pack('c*')
#=> "\xF0\x9F\x91\xBC"

truncated_many_angels.last(3)
#=> "\xF0\x9F\x91"
```

つまり文字のバイト列の途中で切り詰めないようにするため工夫が必要です。Active Support コア拡張機能が提供する [String#truncate_bytes](https://api.rubyonrails.org/v6.1.0/classes/String.html#method-i-truncate_bytes
) を使うと、文字を壊さずに切り詰めることができます。

```rb
too_many_angels = '👼' * 100_000
too_many_angels.bytesize
#=> 400000

# 65,535 バイトに収まる最大の文字数で切り詰めることができる。
truncated_many_angels = too_many_angels.truncate_bytes(65_535, omission: '')
truncated_many_angels.bytesize
#=> 65532
```

## 書記素クラスタを考慮する

「これくらいならメソッドを自作するぜ！」という方もいるかもしれません。例えば [String#each_char](https://docs.ruby-lang.org/ja/latest/method/String/i/each_char.html) を使って以下のように実装します。

```rb
module MyTruncateBytes
  refine String do
    def my_truncate_bytes(limit)
      return dup if bytesize <= limit
    
      cut = ''
      each_char do |char|
        if cut.bytesize + char.bytesize <= limit
          cut << char
        else
          break
        end
      end
    
      cut
    end
  end
end
```

```rb
using MyTruncateBytes

limit = 100 # bytes

engineers = '👨🏿‍💻👩🏿‍💻👩🏼‍💻👨🏻‍💻👩🏾‍💻👨‍💻👨🏼‍💻👨🏾‍💻👨🏽‍💻👩‍💻👩🏻‍💻👩🏽‍💻'
engineers.bytesize
#=> 172

truncated_engineers = engineers.my_truncate_bytes(100)
#=> "👨🏿‍💻👩🏿‍💻👩🏼‍💻👨🏻‍💻👩🏾‍💻👨‍💻👨🏼‍"
truncated_engineers.bytesize
#=> 97
```

切り詰めできました！しかし truncated_engineers をよく見てみると一番右の男性がパソコンを取り上げられています (👨🏼‍💻 → 👨🏼) 。ざっくり言うと 👨🏼‍💻 は複数の絵文字を合成した文字で、その文字の途中で切り詰めてしまったせいですね。

一部の Unicode 絵文字など特殊な文字も正しく処理するためには書記素クラスタ (grapheme cluster) を考慮する必要があります。書記素クラスタについては以下の拙記事を参照してください。

- [[Ruby] 👨‍👩‍👦‍👦 を 1 文字とみなす方法 #Unicode - Qiita](https://qiita.com/QUANON/items/2539feed89cee2290664)

[String#truncate_bytes](https://api.rubyonrails.org/v6.1.0/classes/String.html#method-i-truncate_bytes
) の[実装](https://github.com/rails/rails/blob/v8.0.0/activesupport/lib/active_support/core_ext/string/filters.rb#L101-L126)を簡易化して記載します。この実装ではきちんと書記素クラスタを考慮していますね。

```rb
# Active Support コア拡張機能の String#truncate_bytes 
# (https://github.com/rails/rails/blob/v8.0.0/activesupport/lib/active_support/core_ext/string/filters.rb#L101-L126)
# から omission (省略文字) の概念を取り除いて簡易化した実装。
def truncate_bytes(limit)
  return dup if bytesize <= limit

  cut = ''
  scan(/\X/) do |grapheme|
    if cut.bytesize + grapheme.bytesize <= limit
      cut << grapheme
    else
      break
    end
  end

  cut
end
```

```rb
limit = 100 # bytes

engineers = '👨🏿‍💻👩🏿‍💻👩🏼‍💻👨🏻‍💻👩🏾‍💻👨‍💻👨🏼‍💻👨🏾‍💻👨🏽‍💻👩‍💻👩🏻‍💻👩🏽‍💻'
engineers.bytesize
#=> 172

truncated_engineers = engineers.truncate_bytes(limit, omission: '')
#=> "👨🏿‍💻👩🏿‍💻👩🏼‍💻👨🏻‍💻👩🏾‍💻👨‍💻"
truncated_engineers.bytesize
#=> 86
```

これでパソコンを取り上げられるエンジニアはいなくなりました！

# バージョン情報

```console
$ ruby -v
ruby 3.3.6 (2024-11-05 revision 75015d4c1f) [arm64-darwin24]

$ gem list | grep activesupport
activesupport (8.0.0)
```