週刊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
当初はif
やcase
の階層が5つぐらいになってしまったので、条件をメソッドに切り出して階層を浅くしてみたものの、全然読みやすくなりませんでした😇。
しかもフレームワークにちゃんと乗っていなかったので小数点が扱えていなかった...
とはいえかろうじて動いたので、この時点で作ったspecが最終的なコードを書くときに役立ちました😋。
今度はひらがな読みやってみようかな。