1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2024-11-12

やりたいこと

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

最初に結論

Active Support コア拡張機能が提供する String#truncate_bytes を使います。

limit = 100 # bytes

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

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

長い解説

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

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

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

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 が発生してしまいます。

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 より)

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 文字ごとのバイト数が異なるので、単純に文字数で切り詰めることはできません。

# とても長い文字列を 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 で文字列をいったんバイト数値 (整数) の配列に変換し、Array#slice で 65,535 バイトに切り詰めます。

# バイト数値 (整数) の配列に変換して、要素を 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

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

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

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

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

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

つまり文字のバイト列の途中で切り詰めないようにするため工夫が必要です。Active Support コア拡張機能が提供する String#truncate_bytes を使うと、文字を壊さずに切り詰めることができます。

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 を使って以下のように実装します。

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
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) を考慮する必要があります。書記素クラスタについては以下の拙記事を参照してください。

String#truncate_bytes実装を簡易化して記載します。この実装ではきちんと書記素クラスタを考慮していますね。

# 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
limit = 100 # bytes

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

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

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

バージョン情報

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

$ gem list | grep activesupport
activesupport (8.0.0)
1
0
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
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?