Ruby: 数値をスペルアウトするhumanize gemに日本語ロケールを追加しました

週刊Railsウォッチ(20191029)で取り上げたhumanize gemに日本語ロケールを追加して数値を漢数字読みに変換できるようにしてみました。

humanize gemについて

レシーバーの数値を読み上げ可能な文字列に変換します。デフォルトロケールは英語です。

require 'humanize'
(1000..1050).to_a.map(&:humanize)
=> ["one thousand", "one thousand and one", "one thousand and two", "one thousand and three", "one thousand and four", "one thousand and five", "one thousand and six", "one thousand and seven", "one thousand and eight", "one thousand and nine", "one thousand and ten", "one thousand and eleven", "one thousand and twelve", "one thousand and thirteen", "one thousand and fourteen", "one thousand and fifteen", "one thousand and sixteen", "one thousand and seventeen", "one thousand and eighteen", "one thousand and nineteen", "one thousand and twenty", "one thousand and twenty-one", "one thousand and twenty-two", "one thousand and twenty-three", "one thousand and twenty-four", "one thousand and twenty-five", "one thousand and twenty-six", "one thousand and twenty-seven", "one thousand and twenty-eight", "one thousand and twenty-nine", "one thousand and thirty", "one thousand and thirty-one", "one thousand and thirty-two", "one thousand and thirty-three", "one thousand and thirty-four", "one thousand and thirty-five", "one thousand and thirty-six", "one thousand and thirty-seven", "one thousand and thirty-eight", "one thousand and thirty-nine", "one thousand and forty", "one thousand and forty-one", "one thousand and forty-two", "one thousand and forty-three", "one thousand and forty-four", "one thousand and forty-five", "one thousand and forty-six", "one thousand and forty-seven", "one thousand and forty-eight", "one thousand and forty-nine", "one thousand and fifty"]

小数点や無限大も使えますが、ロケールによってはまだできてないものもあります。

日本語ロケール

この間プルリクを投げていましたが、ちょうど今朝マージされました😂。実はrubygemをいじったのはこれが初めてです😅。

使い方

通常のgemと同様に、以下を実行するかGemfileに書いてbundle installしてインストールすれば、require 'humanize'することで使えます。

$ gem install humanize

以下のように負数や小数やFloat::INFINITYも扱えます。

require 'humanize'

2019.humanize(locale: :jp)
#=> "二千十九"

-123422223.48948753.humanize(locale: :jp)
#=> マイナス一億二千三百四十二万二千二百二十三・四八九四八八

Float::INFINITY.humanize
#=> "無限大"

(Float::INFINITY - Float::INFINITY).humanize
#=> "未定義"

毎回(locale: :jp)を書くのがだるければ、Humanize.config.default_locale = :jpでロケールを設定できます。

require 'humanize'
Humanize.config.default_locale = :jp

puts "#{5000000000000000.humanize}円欲しい"
#=> 五千兆円欲しい

なお、(decimals_as: :number)を指定すると小数以下が桁表示になります。他のロケールではともかく、日本語では意味はないでしょうね。

3.1415926535897932384626.humanize(decimals_as: :number)
#=> "三・十四兆一千五百九十二億六千五百三十五万八千九百七十九"

めいいっぱいにやってみました。

require 'humanize'
Humanize.config.default_locale = :jp

('9' * 72).to_i.humanize

結果が長いので以下に置きます。

九千九百九十九無量大数九千九百九十九不可思議九千九百九十九那由他九千九百九十九阿僧祇九千九百九十九恒河沙九千九百九十九極九千九百九十九載九千九百九十九正九千九百九十九澗九千九百九十九溝九千九百九十九穣九千九百九十九𥝱九千九百九十九垓九千九百九十九京九千九百九十九兆九千九百九十九億九千九百九十九万九千九百九十九

参考: 類似のgem

なお、兆までの数値は以下のgemで漢数字に漢数字を半角数字に変換できます。

追記(2019/11/05): zen_to_i gemの機能紹介に誤りがありました。お詫びして訂正いたします🙇。

年号の漢数字であればwareki gemで対応できます。「10月」「十月」「一〇月」「拾月」「什月」といったバリエーションにも対応しています😳。

:jpロケールのコード

lib/humanize/locales/constants以下にロケールの定数を定義します。この定数はロケール以外にメインのlib/humanize.rbからも参照され、小数点や無限大やゼロ値の場合に用いられます。

# lib/humanize/locales/constants/jp.rb
module Humanize
  class Jp
    INFINITY = '無限大'.freeze
    UNDEFINED = '未定義'.freeze
    NEGATIVE = 'マイナス'.freeze
    POINT = '・'.freeze
    LOTS = ['', '万', '億', '兆', '京', '垓', '𥝱', '穣', '溝', '澗', '正', '載', '極', '恒河沙', '阿僧祇', '那由他', '不可思議', '無量大数'].freeze
    SUB_ONE_GROUPING = %w[〇 一 二 三 四 五 六 七 八 九 十 十一 十二 十三 十四 十五 十六 十七 十八 十九 二十 二十一 二十二 二十三 二十四 二十五 二十六 二十七 二十八 二十九 三十 三十一 三十二 三十三 三十四 三十五 三十六 三十七 三十八 三十九 四十 四十一 四十二 四十三 四十四 四十五 四十六 四十七 四十八 四十九 五十 五十一 五十二 五十三 五十四 五十五 五十六
    # (略)
 九千九百九十九].freeze

SUB_ONE_GROUPING定数のリストがゼロから9999までと長大ですが、リスト化することでアルゴリズムがシンプルになり、ロケールごとの違いもここで吸収できます。

以下は:jpロケール処理のコアです。:enロケールをコピペして「, and」の追加処理を除去した程度です。

# lib/humanize/locales/jp.rb
require_relative 'constants/jp'

module Humanize
  class Jp
    def humanize(number)
      iteration = 0
      parts = []
      until number.zero?
        number, remainder = number.divmod(10000)
        unless remainder.zero?
          add_grouping(parts, iteration)
          parts << SUB_ONE_GROUPING[remainder]
        end

        iteration += 1
      end

      parts
    end

    private

    def add_grouping(parts, iteration)
      grouping = LOTS[iteration]
      return unless grouping

      parts << "#{grouping}"
    end
  end
end

変換の主な仕様

命数は「無量大数」まで

「不可説不可説転」までやってみたかったのですが、系列が合わない(10^4で進行しない)ので断念しました。

参考: 命数法 - Wikipedia

1000は「一千」に変換する

10や100や1000を漢数字で表す場合には以下の慣習を考慮する必要があります。

  • 10を「十」と書くことはあっても「一十」と書くことはない
    • なお20以上なら「二十」などと書く
  • 100を「百」と書くことはあっても「一百」と書くことはない
    • 同じく200以上なら「二百」などと書く
  • 1000は上と異なり、「千」とも「一千」とも書ける
    • 特に「一千万」「一千億」は必ず「一」を付けると考えてよい

このように、1000については「千」「一千」という2とおりの表記が考えられ、どちらを取るべきか考えました。

その結果、「一千」なら「一千万」「一千億」とも整合するので「一千」に統一することにしました。「一千」をどうしても「千」にしたいのであれば後から独自に除去する手もあります。

(参考)ボツにした:jpロケールのコード

実は、当初のコードはhumanizeのフレームワークに乗っかっていませんでした。以下のように最小限の定数を定義して、アルゴリズムでどこまでやれるかやってみたかったのでした。

# lib/humanize/locales/constants/jp.rb
module Humanize
  class Jp
    POINT = '・'.freeze
    INFINITY = '無限大'.freeze
    UNDEFINED = '未定義'.freeze
    NEGATIVE = 'マイナス'.freeze
    LOTS_ONE = %w[万 億 兆 京 垓 𥝱 穣 溝 澗 正 載 極 恒河沙 阿僧祇 那由他 不可思議 無量大数].freeze
    LOTS_TWO = %w[十 百 千].freeze
    SUB_ONE_GROUPING = ['〇', '一', '二', '三', '四', '五', '六', '七', '八', '九'].freeze
  end
end

そして漢数字への変換をアルゴリズムで処理してみると、以下のようなおぞましいコードになってしまい、メンテできそうになかったので最終的に放棄しました😇。

# lib/humanize/locales/jp.rb
require_relative 'constants/jp'

module Humanize
  class Jp
    def humanize(number)
      parts = []
      group = 0
      target = number.digits
      target.each_with_index do |digit, index|
        group = index % 4
        case
        when zero_on_first_3_digits?(digit, index)
          next
        when zero_on_lower_3_digits_in_higher_group?(digit, group)
          next
        when all_zeroes_on_4_digits_in_group?(digit, group, index, target)
          add_grouping(parts, (index / 4), Jp::LOTS_ONE)
        when one_thousand?(digit, group)
          add_grouping(parts, group, Jp::LOTS_TWO)
          parts << Jp::SUB_ONE_GROUPING[digit]
        when two_or_more_thousands?(digit, group)
          add_grouping(parts, group, Jp::LOTS_TWO)
          parts << Jp::SUB_ONE_GROUPING[digit]
        when none_thousand?(digit, group)
          add_grouping(parts, group, Jp::LOTS_TWO)
        when any_zeroes_on_4_digits_in_group?(digit, group, index, target)
          add_grouping(parts, (index / 4), Jp::LOTS_ONE)
          parts << Jp::SUB_ONE_GROUPING[digit]
        when nonzero_on_highest_digit_of_group?(digit, group)
          parts << Jp::SUB_ONE_GROUPING[digit]
        end
      end
      parts
    end

    private

    # returns true in the cases like 0, 0 of 202, 0 of 2_000
    def zero_on_first_3_digits?(digit, index)
      digit.zero? && index < 4
    end

    # returns true in the cases like 0 of 3000_9999
    def zero_on_lower_3_digits_in_higher_group?(digit, group)
      digit.zero? && group.nonzero?
    end

    # returns true in the cases like 0 of 1_1000_9999, 0 of 1_0100_9999
    def all_zeroes_on_4_digits_in_group?(digit, group, index, target)
      digit.zero? && group.zero? && target[index, 4] != [0, 0, 0, 0] && index.nonzero?
    end

    # returns true in the cases like 1 of 1000_9999
    def one_thousand?(digit, group)
      digit == 1 && group == 3
    end

    # returns true in the cases like 2 of 2000_9999
    def two_or_more_thousands?(digit, group)
      digit != 1 && group.nonzero?
    end

    # returns true in the cases like 1 of 9111_9999
    def none_thousand?(digit, group)
      digit == 1 && group.nonzero?
    end

    # returns true in the cases like 0 of 1_0000_9999
    def any_zeroes_on_4_digits_in_group?(digit, group, index, target)
      digit.nonzero? && group.zero? && target[index, 4] != [0, 0, 0, 0] && index.nonzero?
    end

    # returns true in the cases like 1, 1 of 21, 1 of 321, 1 of 4321
    def nonzero_on_highest_digit_of_group?(digit, group)
      digit.nonzero? && group.zero?
    end

    def add_grouping(parts, iteration, lots)
      grouping = lots[iteration - 1]
      return unless grouping

      parts << grouping
    end
  end
end

当初はifcaseの階層が5つぐらいになってしまったので、条件をメソッドに切り出して階層を浅くしてみたものの、全然読みやすくなりませんでした😇。

しかもフレームワークにちゃんと乗っていなかったので小数点が扱えていなかった…

とはいえかろうじて動いたので、この時点で作ったspecが最終的なコードを書くときに役立ちました😋。

今度はひらがな読みやってみようかな。

関連記事

Ruby: CSVでヘッダとボディを同時に定義するやり方

デザインも頼めるシステム開発会社をお探しならBPS株式会社までどうぞ 開発エンジニア積極採用中です! Ruby on Rails の開発なら実績豊富なBPS

この記事の著者

hachi8833

Twitter: @hachi8833、GitHub: @hachi8833 コボラー、ITコンサル、ローカライズ業界、Rails開発を経てTechRachoの編集・記事作成を担当。 これまでにRuby on Rails チュートリアル第2版の監修および半分程度を翻訳、Railsガイドの初期翻訳ではほぼすべてを翻訳。その後も折に触れて更新翻訳中。 かと思うと、正規表現の粋を尽くした日本語エラーチェックサービス enno.jpを運営。 実は最近Go言語が好きで、Goで書かれたRubyライクなGoby言語のメンテナーでもある。 仕事に関係ないすっとこブログ「あけてくれ」は2000年頃から多少の中断をはさんで継続、現在はnote.muに移転。

hachi8833の書いた記事

夏のTechRachoフェア2019

週刊Railsウォッチ

インフラ

ActiveSupport探訪シリーズ