human_number 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +63 -0
- data/CHANGELOG.md +90 -0
- data/CLAUDE.md +219 -0
- data/Gemfile +23 -0
- data/LICENSE +21 -0
- data/README.md +323 -0
- data/Rakefile +12 -0
- data/config/locales/bn.yml +8 -0
- data/config/locales/de.yml +9 -0
- data/config/locales/en-GB.yml +10 -0
- data/config/locales/en-IN.yml +8 -0
- data/config/locales/en.yml +10 -0
- data/config/locales/es.yml +9 -0
- data/config/locales/fr.yml +9 -0
- data/config/locales/hi.yml +8 -0
- data/config/locales/ja.yml +15 -0
- data/config/locales/ko.yml +15 -0
- data/config/locales/ur.yml +8 -0
- data/config/locales/zh-CN.yml +9 -0
- data/config/locales/zh-TW.yml +9 -0
- data/config/locales/zh.yml +12 -0
- data/human_number.gemspec +42 -0
- data/lib/human_number/formatters/currency.rb +73 -0
- data/lib/human_number/formatters/number.rb +462 -0
- data/lib/human_number/locale_support.rb +125 -0
- data/lib/human_number/rails/helpers.rb +98 -0
- data/lib/human_number/railtie.rb +23 -0
- data/lib/human_number/version.rb +5 -0
- data/lib/human_number.rb +196 -0
- metadata +126 -0
@@ -0,0 +1,462 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "../locale_support"
|
4
|
+
|
5
|
+
module HumanNumber
|
6
|
+
module Formatters
|
7
|
+
class Number
|
8
|
+
extend ActionView::Helpers::NumberHelper
|
9
|
+
|
10
|
+
# Constants for string literals and magic numbers
|
11
|
+
ZERO_STRING = "0"
|
12
|
+
EMPTY_SEPARATOR = ""
|
13
|
+
SPACE_SEPARATOR = " "
|
14
|
+
DECIMAL_FORMAT_TEMPLATE = "%.%df"
|
15
|
+
MINIMUM_UNIT_COUNT = 1.0
|
16
|
+
SINGLE_DIGIT_LIMIT = 1
|
17
|
+
|
18
|
+
# I18n key templates
|
19
|
+
I18N_DECIMAL_UNITS_KEY = "number.human.decimal_units"
|
20
|
+
I18N_ABBR_UNITS_SECTION = "abbr_units"
|
21
|
+
I18N_UNITS_SECTION = "units"
|
22
|
+
|
23
|
+
class << self
|
24
|
+
# Internal formatter for numbers with intelligent abbreviations.
|
25
|
+
# All parameters are required - use HumanNumber.human_number for user-friendly interface.
|
26
|
+
#
|
27
|
+
# @param number [Numeric] Number to format
|
28
|
+
# @param locale [Symbol, String] Locale for unit system selection
|
29
|
+
# @param abbr_units [Boolean] Use abbreviated symbols vs full words
|
30
|
+
# @param max_digits [Integer, nil] Max digits after decimal, nil for complete mode
|
31
|
+
# @param min_unit [Integer, nil] Minimum unit threshold for abbreviation
|
32
|
+
# @param trim_zeros [Boolean] Remove trailing decimal zeros
|
33
|
+
# @return [String] Formatted number string
|
34
|
+
#
|
35
|
+
# @api private
|
36
|
+
def format(number, locale:, abbr_units:, max_digits:, min_unit:, trim_zeros:)
|
37
|
+
locale = locale.to_sym
|
38
|
+
|
39
|
+
# Direct delegation to appropriate system class
|
40
|
+
system_class = determine_system_class(locale)
|
41
|
+
system_class.format_number(
|
42
|
+
number,
|
43
|
+
locale:,
|
44
|
+
abbr_units:,
|
45
|
+
max_digits:,
|
46
|
+
min_unit:,
|
47
|
+
trim_zeros:
|
48
|
+
)
|
49
|
+
end
|
50
|
+
|
51
|
+
# Default options for human number formatting
|
52
|
+
def default_options
|
53
|
+
{
|
54
|
+
abbr_units: true,
|
55
|
+
max_digits: 2,
|
56
|
+
min_unit: nil,
|
57
|
+
trim_zeros: true,
|
58
|
+
}.freeze
|
59
|
+
end
|
60
|
+
|
61
|
+
# Default options for currency number formatting (tighter display)
|
62
|
+
def default_currency_number_options
|
63
|
+
{
|
64
|
+
abbr_units: true,
|
65
|
+
max_digits: 1,
|
66
|
+
min_unit: nil,
|
67
|
+
trim_zeros: true,
|
68
|
+
}.freeze
|
69
|
+
end
|
70
|
+
|
71
|
+
# Formats a number using Rails' currency precision rules for the given currency.
|
72
|
+
# This ensures consistent precision regardless of display locale.
|
73
|
+
#
|
74
|
+
# @param number [Numeric] The number to format
|
75
|
+
# @param currency_code [String] ISO 4217 currency code
|
76
|
+
# @param locale [Symbol] Display locale (for context)
|
77
|
+
# @return [String] Formatted number with currency-appropriate precision
|
78
|
+
def format_with_currency_precision(number, currency_code:, locale:)
|
79
|
+
locale = locale.to_sym
|
80
|
+
|
81
|
+
# Use currency's native locale for precision rules
|
82
|
+
precision_locale = LocaleSupport.currency_precision_locale(currency_code, locale)
|
83
|
+
|
84
|
+
I18n.with_locale(precision_locale) do
|
85
|
+
number_to_currency(number, format: "%n", locale: precision_locale)
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
private
|
90
|
+
|
91
|
+
def determine_system_class(locale)
|
92
|
+
locale_sym = locale.to_sym
|
93
|
+
|
94
|
+
case locale_sym
|
95
|
+
when *EastAsianSystem::TARGET_LOCALES
|
96
|
+
EastAsianSystem
|
97
|
+
when *IndianSystem::TARGET_LOCALES
|
98
|
+
IndianSystem
|
99
|
+
else
|
100
|
+
DefaultSystem
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
# Default number system (thousand, million, billion, trillion)
|
106
|
+
class DefaultSystem
|
107
|
+
TARGET_LOCALES = %i[en es fr de it pt ru nl sv da no].freeze
|
108
|
+
|
109
|
+
UNIT_DEFINITIONS = [
|
110
|
+
{ key: :trillion, divisor: 1_000_000_000_000 },
|
111
|
+
{ key: :billion, divisor: 1_000_000_000 },
|
112
|
+
{ key: :million, divisor: 1_000_000 },
|
113
|
+
{ key: :thousand, divisor: 1_000 },
|
114
|
+
].freeze
|
115
|
+
|
116
|
+
class << self
|
117
|
+
def format_number(number, locale:, abbr_units: true, min_unit: nil, max_digits: 2, trim_zeros: true)
|
118
|
+
return ZERO_STRING if number.zero?
|
119
|
+
|
120
|
+
parts = if max_digits.nil?
|
121
|
+
format_in_complete_mode(number, locale, abbr_units, min_unit)
|
122
|
+
else
|
123
|
+
format_in_abbreviated_mode(number, locale, abbr_units, max_digits, trim_zeros, min_unit)
|
124
|
+
end
|
125
|
+
|
126
|
+
return number.to_s if parts.nil?
|
127
|
+
|
128
|
+
finalize_result(number, parts)
|
129
|
+
end
|
130
|
+
|
131
|
+
def format_in_complete_mode(number, locale, abbr_units, min_unit)
|
132
|
+
# Complete mode shows all units: "1M 234K 567"
|
133
|
+
unit_breakdown = break_down_into_all_units(number)
|
134
|
+
units_above_threshold = filter_by_minimum_unit_threshold(unit_breakdown, min_unit)
|
135
|
+
return nil if units_above_threshold.empty?
|
136
|
+
|
137
|
+
format_all_unit_parts(units_above_threshold, locale, abbr_units)
|
138
|
+
end
|
139
|
+
|
140
|
+
def format_in_abbreviated_mode(number, locale, abbr_units, max_digits, trim_zeros, min_unit)
|
141
|
+
# Abbreviated mode shows single largest unit: "1.2M"
|
142
|
+
unit_breakdown = break_down_into_largest_unit(number)
|
143
|
+
units_above_threshold = filter_by_minimum_unit_threshold(unit_breakdown, min_unit)
|
144
|
+
return nil if units_above_threshold.empty?
|
145
|
+
|
146
|
+
format_abbreviated_unit_parts(units_above_threshold, locale, abbr_units, max_digits, trim_zeros)
|
147
|
+
end
|
148
|
+
|
149
|
+
private
|
150
|
+
|
151
|
+
def breakdown_number(number)
|
152
|
+
result = []
|
153
|
+
remaining = number.abs
|
154
|
+
|
155
|
+
unit_definitions.each do |unit|
|
156
|
+
next unless remaining >= unit[:divisor]
|
157
|
+
|
158
|
+
count = remaining.to_f / unit[:divisor]
|
159
|
+
result << { unit_key: unit[:key], count: count, divisor: unit[:divisor] }
|
160
|
+
break # Only use the largest applicable unit
|
161
|
+
end
|
162
|
+
|
163
|
+
# If no unit matched, use the raw number
|
164
|
+
result << { unit_key: :units, count: remaining, divisor: 1 } if result.empty?
|
165
|
+
|
166
|
+
result
|
167
|
+
end
|
168
|
+
|
169
|
+
# Find the largest applicable unit for abbreviated display
|
170
|
+
# Example: 1,234,567 -> [{million: 1.234567}]
|
171
|
+
def break_down_into_largest_unit(number)
|
172
|
+
absolute_amount = number.abs
|
173
|
+
|
174
|
+
# Find the first (largest) unit where the count would be >= 1.0
|
175
|
+
unit_definitions.each do |unit|
|
176
|
+
unit_count = absolute_amount.to_f / unit[:divisor]
|
177
|
+
|
178
|
+
return [create_unit_part(unit[:key], unit_count, unit[:divisor])] if unit_count >= MINIMUM_UNIT_COUNT
|
179
|
+
end
|
180
|
+
|
181
|
+
# If no unit is applicable, return the raw number
|
182
|
+
[create_unit_part(:units, absolute_amount, 1)]
|
183
|
+
end
|
184
|
+
|
185
|
+
def unit_definitions
|
186
|
+
UNIT_DEFINITIONS
|
187
|
+
end
|
188
|
+
|
189
|
+
# Helper method to create consistent unit part structure
|
190
|
+
def create_unit_part(unit_key, count, divisor)
|
191
|
+
{ unit_key: unit_key, count: count, divisor: divisor }
|
192
|
+
end
|
193
|
+
|
194
|
+
# Filter out units below the minimum threshold
|
195
|
+
def filter_by_minimum_unit_threshold(breakdown, min_unit)
|
196
|
+
return breakdown unless min_unit
|
197
|
+
|
198
|
+
breakdown.select { |unit_info| unit_info[:divisor] >= min_unit }
|
199
|
+
end
|
200
|
+
|
201
|
+
# Format unit parts for abbreviated display (e.g., "1.2M")
|
202
|
+
def format_abbreviated_unit_parts(breakdown, locale, abbr_units, max_digits, trim_zeros)
|
203
|
+
breakdown.map do |unit_info|
|
204
|
+
if unit_info[:unit_key] == :units
|
205
|
+
# Raw numbers don't need significant digits rounding
|
206
|
+
format_raw_number_count(unit_info[:count], max_digits, trim_zeros)
|
207
|
+
else
|
208
|
+
# Format with unit symbol (e.g., "1.2" + "M" = "1.2M")
|
209
|
+
format_number_with_unit_symbol(unit_info, locale, abbr_units, max_digits, trim_zeros)
|
210
|
+
end
|
211
|
+
end
|
212
|
+
end
|
213
|
+
|
214
|
+
# Format raw number without unit symbols
|
215
|
+
def format_raw_number_count(count, max_digits, trim_zeros)
|
216
|
+
format_unit_count(count, max_digits:, trim_zeros:, apply_rounding: false)
|
217
|
+
end
|
218
|
+
|
219
|
+
# Format number with unit symbol (e.g., "1.2M")
|
220
|
+
def format_number_with_unit_symbol(unit_info, locale, abbr_units, max_digits, trim_zeros)
|
221
|
+
symbol = lookup_unit_symbol(locale, unit_info[:unit_key], abbr_units)
|
222
|
+
format_with_symbol(unit_info[:count], symbol, abbr_units, max_digits:, trim_zeros:)
|
223
|
+
end
|
224
|
+
|
225
|
+
# Format unit with symbol for complete mode (e.g., "234K")
|
226
|
+
def format_complete_unit_with_symbol(unit_info, locale, abbr_units)
|
227
|
+
symbol = lookup_unit_symbol(locale, unit_info[:unit_key], abbr_units)
|
228
|
+
count_text = unit_info[:count].to_i.to_s
|
229
|
+
separator = determine_unit_value_separator(abbr_units)
|
230
|
+
formatted_symbol = apply_symbol_formatting_rules(symbol, abbr_units)
|
231
|
+
"#{count_text}#{separator}#{formatted_symbol}"
|
232
|
+
end
|
233
|
+
|
234
|
+
def format_unit_count(count, max_digits: 2, trim_zeros: true, apply_rounding: true)
|
235
|
+
return count.to_s unless count.is_a?(Numeric)
|
236
|
+
|
237
|
+
rounded = apply_rounding ? round_to_significant_digits(count, max_digits) : count
|
238
|
+
|
239
|
+
format_rounded_count(rounded, max_digits, trim_zeros, apply_rounding, count)
|
240
|
+
end
|
241
|
+
|
242
|
+
def format_rounded_count(rounded, max_digits, trim_zeros, apply_rounding, original_count)
|
243
|
+
if trim_zeros
|
244
|
+
format_with_trimmed_zeros(rounded)
|
245
|
+
elsif apply_rounding
|
246
|
+
format_with_fixed_decimals(rounded, max_digits)
|
247
|
+
else
|
248
|
+
original_count.to_s
|
249
|
+
end
|
250
|
+
end
|
251
|
+
|
252
|
+
def format_with_trimmed_zeros(rounded)
|
253
|
+
if rounded == rounded.to_i
|
254
|
+
rounded.to_i.to_s
|
255
|
+
else
|
256
|
+
rounded.to_s
|
257
|
+
end
|
258
|
+
end
|
259
|
+
|
260
|
+
def format_with_fixed_decimals(rounded, max_digits)
|
261
|
+
integer_digits = rounded.to_i.to_s.length
|
262
|
+
decimal_places = [0, max_digits - integer_digits].max
|
263
|
+
format("%.#{decimal_places}f", rounded)
|
264
|
+
end
|
265
|
+
|
266
|
+
def round_to_significant_digits(number, digits)
|
267
|
+
return handle_edge_cases(number, digits) if should_handle_edge_case?(number, digits)
|
268
|
+
|
269
|
+
abs_num = number.abs
|
270
|
+
int_digits = count_integer_digits(abs_num.to_i)
|
271
|
+
|
272
|
+
result = calculate_significant_digits_result(abs_num, digits, int_digits)
|
273
|
+
apply_sign(number, result)
|
274
|
+
end
|
275
|
+
|
276
|
+
def should_handle_edge_case?(number, digits)
|
277
|
+
number.zero? || digits <= 0
|
278
|
+
end
|
279
|
+
|
280
|
+
def handle_edge_cases(number, _digits)
|
281
|
+
return 0.0 if number.zero?
|
282
|
+
|
283
|
+
number
|
284
|
+
end
|
285
|
+
|
286
|
+
def calculate_significant_digits_result(abs_num, digits, int_digits)
|
287
|
+
if int_digits >= digits
|
288
|
+
truncate_to_digits(abs_num.to_i, digits)
|
289
|
+
else
|
290
|
+
round_with_decimals(abs_num, digits, int_digits)
|
291
|
+
end
|
292
|
+
end
|
293
|
+
|
294
|
+
def apply_sign(original_number, result)
|
295
|
+
original_number.negative? ? -result : result
|
296
|
+
end
|
297
|
+
|
298
|
+
def count_integer_digits(int_part)
|
299
|
+
int_part.zero? ? SINGLE_DIGIT_LIMIT : int_part.to_s.length
|
300
|
+
end
|
301
|
+
|
302
|
+
def truncate_to_digits(int_part, digits)
|
303
|
+
int_part.to_s[0, digits].to_i.to_f
|
304
|
+
end
|
305
|
+
|
306
|
+
def round_with_decimals(abs_num, digits, int_digits)
|
307
|
+
decimal_places = digits - int_digits
|
308
|
+
(abs_num * (10**decimal_places)).round / (10**decimal_places).to_f
|
309
|
+
end
|
310
|
+
|
311
|
+
# Look up the localized symbol for a unit (e.g., 'M' for million)
|
312
|
+
def lookup_unit_symbol(locale, unit_key, abbr_units)
|
313
|
+
section = abbr_units ? I18N_ABBR_UNITS_SECTION : I18N_UNITS_SECTION
|
314
|
+
|
315
|
+
I18n.t("#{I18N_DECIMAL_UNITS_KEY}.#{section}.#{unit_key}", locale:, default: nil) ||
|
316
|
+
I18n.t("#{I18N_DECIMAL_UNITS_KEY}.#{section}.#{unit_key}", locale: :en, default: nil) ||
|
317
|
+
unit_key.to_s.upcase
|
318
|
+
end
|
319
|
+
|
320
|
+
def format_with_symbol(value, symbol, abbr_units, max_digits: 2, trim_zeros: true)
|
321
|
+
# Apply significant digits rounding to unit values
|
322
|
+
formatted_count = format_unit_count(value, max_digits:, trim_zeros:, apply_rounding: true)
|
323
|
+
separator = determine_unit_value_separator(abbr_units)
|
324
|
+
formatted_symbol = apply_symbol_formatting_rules(symbol, abbr_units)
|
325
|
+
"#{formatted_count}#{separator}#{formatted_symbol}"
|
326
|
+
end
|
327
|
+
|
328
|
+
# Determine separator between number and unit ("1M" vs "1 million")
|
329
|
+
def determine_unit_value_separator(abbr_units)
|
330
|
+
abbr_units ? EMPTY_SEPARATOR : SPACE_SEPARATOR
|
331
|
+
end
|
332
|
+
|
333
|
+
# Apply locale-specific formatting rules to unit symbols
|
334
|
+
def apply_symbol_formatting_rules(symbol, abbr_units)
|
335
|
+
return symbol if abbr_units || symbol.length <= 1
|
336
|
+
|
337
|
+
symbol.downcase # Western lowercase rule for full words
|
338
|
+
end
|
339
|
+
|
340
|
+
# Break down number into all applicable units for complete display
|
341
|
+
# Example: 1,234,567 -> [{million: 1}, {thousand: 234}, {units: 567}]
|
342
|
+
def break_down_into_all_units(number)
|
343
|
+
unit_breakdown = UnitBreakdown.new(number.abs, unit_definitions)
|
344
|
+
unit_breakdown.extract_all_units
|
345
|
+
end
|
346
|
+
|
347
|
+
# Helper class to manage unit breakdown state
|
348
|
+
class UnitBreakdown
|
349
|
+
def initialize(amount, unit_definitions)
|
350
|
+
@remaining_amount = amount
|
351
|
+
@unit_definitions = unit_definitions
|
352
|
+
@unit_parts = []
|
353
|
+
end
|
354
|
+
|
355
|
+
def extract_all_units
|
356
|
+
extract_major_units
|
357
|
+
add_remaining_as_units
|
358
|
+
@unit_parts
|
359
|
+
end
|
360
|
+
|
361
|
+
private
|
362
|
+
|
363
|
+
def extract_major_units
|
364
|
+
@unit_definitions.each do |unit|
|
365
|
+
extract_single_unit(unit) if @remaining_amount >= unit[:divisor]
|
366
|
+
end
|
367
|
+
end
|
368
|
+
|
369
|
+
def extract_single_unit(unit)
|
370
|
+
unit_count = (@remaining_amount / unit[:divisor]).to_i
|
371
|
+
return if unit_count.zero?
|
372
|
+
|
373
|
+
@unit_parts << create_unit_part(unit[:key], unit_count, unit[:divisor])
|
374
|
+
@remaining_amount %= unit[:divisor]
|
375
|
+
end
|
376
|
+
|
377
|
+
def add_remaining_as_units
|
378
|
+
return unless @remaining_amount.positive?
|
379
|
+
|
380
|
+
@unit_parts << create_unit_part(:units, @remaining_amount, 1)
|
381
|
+
end
|
382
|
+
|
383
|
+
def create_unit_part(unit_key, count, divisor)
|
384
|
+
{ unit_key: unit_key, count: count, divisor: divisor }
|
385
|
+
end
|
386
|
+
end
|
387
|
+
|
388
|
+
# Format unit parts for complete display (e.g., "1M 234K 567")
|
389
|
+
def format_all_unit_parts(breakdown, locale, abbr_units)
|
390
|
+
breakdown.map do |unit_info|
|
391
|
+
if unit_info[:unit_key] == :units
|
392
|
+
# Raw numbers shown as integers in complete mode
|
393
|
+
unit_info[:count].to_i.to_s
|
394
|
+
else
|
395
|
+
# Format each unit part with its symbol
|
396
|
+
format_complete_unit_with_symbol(unit_info, locale, abbr_units)
|
397
|
+
end
|
398
|
+
end
|
399
|
+
end
|
400
|
+
|
401
|
+
# Apply sign and join all parts into final result
|
402
|
+
def finalize_result(number, formatted_parts)
|
403
|
+
combined_result = formatted_parts.join(SPACE_SEPARATOR)
|
404
|
+
number.negative? ? "-#{combined_result}" : combined_result
|
405
|
+
end
|
406
|
+
end
|
407
|
+
end
|
408
|
+
|
409
|
+
# East Asian number system (thousand, ten_thousand, hundred_million, trillion)
|
410
|
+
class EastAsianSystem < DefaultSystem
|
411
|
+
TARGET_LOCALES = %i[ko ja zh zh-CN zh-TW].freeze
|
412
|
+
|
413
|
+
UNIT_DEFINITIONS = [
|
414
|
+
{ key: :trillion, divisor: 1_000_000_000_000 },
|
415
|
+
{ key: :hundred_million, divisor: 100_000_000 },
|
416
|
+
{ key: :ten_thousand, divisor: 10_000 },
|
417
|
+
{ key: :thousand, divisor: 1_000 },
|
418
|
+
].freeze
|
419
|
+
|
420
|
+
class << self
|
421
|
+
private
|
422
|
+
|
423
|
+
def unit_definitions
|
424
|
+
UNIT_DEFINITIONS
|
425
|
+
end
|
426
|
+
|
427
|
+
def format_with_symbol(value, symbol, _abbr_units, max_digits: 2, trim_zeros: true)
|
428
|
+
# East Asian: no separator regardless of abbr_units
|
429
|
+
# Apply significant digits rounding to unit values
|
430
|
+
formatted_count = format_unit_count(value, max_digits:, trim_zeros:, apply_rounding: true)
|
431
|
+
formatted_symbol = apply_symbol_formatting_rules(symbol, true)
|
432
|
+
"#{formatted_count}#{formatted_symbol}"
|
433
|
+
end
|
434
|
+
|
435
|
+
# Keep original case for Asian characters (만, 억, 조)
|
436
|
+
def apply_symbol_formatting_rules(symbol, _abbr_units)
|
437
|
+
symbol
|
438
|
+
end
|
439
|
+
end
|
440
|
+
end
|
441
|
+
|
442
|
+
# Indian subcontinent number system (thousand, lakh, crore)
|
443
|
+
class IndianSystem < DefaultSystem
|
444
|
+
TARGET_LOCALES = %i[hi ur bn en-IN].freeze
|
445
|
+
|
446
|
+
UNIT_DEFINITIONS = [
|
447
|
+
{ key: :crore, divisor: 10_000_000 },
|
448
|
+
{ key: :lakh, divisor: 100_000 },
|
449
|
+
{ key: :thousand, divisor: 1_000 },
|
450
|
+
].freeze
|
451
|
+
|
452
|
+
class << self
|
453
|
+
private
|
454
|
+
|
455
|
+
def unit_definitions
|
456
|
+
UNIT_DEFINITIONS
|
457
|
+
end
|
458
|
+
end
|
459
|
+
end
|
460
|
+
end
|
461
|
+
end
|
462
|
+
end
|
@@ -0,0 +1,125 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module HumanNumber
|
4
|
+
# Shared locale support logic for both Number and Currency formatters.
|
5
|
+
#
|
6
|
+
# This module provides centralized currency-to-locale mappings and locale
|
7
|
+
# resolution logic to ensure consistent precision and formatting rules
|
8
|
+
# across different formatters.
|
9
|
+
module LocaleSupport
|
10
|
+
# Mapping of currency codes to their primary native locales
|
11
|
+
# Used to determine appropriate precision and formatting rules
|
12
|
+
CURRENCY_NATIVE_LOCALES = {
|
13
|
+
"KRW" => [:ko],
|
14
|
+
"JPY" => [:ja],
|
15
|
+
"USD" => [:en],
|
16
|
+
"EUR" => %i[de fr es it nl],
|
17
|
+
"GBP" => %i[en en-GB],
|
18
|
+
"CAD" => %i[en en-CA fr fr-CA],
|
19
|
+
"CNY" => %i[zh zh-CN],
|
20
|
+
"CHF" => %i[de fr it],
|
21
|
+
"INR" => %i[hi en],
|
22
|
+
"RUB" => [:ru],
|
23
|
+
"BRL" => %i[pt pt-BR],
|
24
|
+
"MXN" => [:es],
|
25
|
+
"AUD" => %i[en en-AU],
|
26
|
+
"NZD" => %i[en en-NZ],
|
27
|
+
"SEK" => [:sv],
|
28
|
+
"NOK" => %i[no nb],
|
29
|
+
"DKK" => [:da],
|
30
|
+
"SGD" => %i[en en-SG zh zh-SG],
|
31
|
+
"HKD" => %i[zh zh-HK en en-HK],
|
32
|
+
"ZAR" => %i[en en-ZA af],
|
33
|
+
"TRY" => [:tr],
|
34
|
+
"ARS" => %i[es es-AR],
|
35
|
+
"IDR" => [:id],
|
36
|
+
"THB" => [:th],
|
37
|
+
"MYR" => %i[ms en en-MY],
|
38
|
+
"PHP" => %i[en en-PH tl],
|
39
|
+
"VND" => [:vi],
|
40
|
+
}.freeze
|
41
|
+
|
42
|
+
# Keys supported for native currency formatting
|
43
|
+
SUPPORTED_NATIVE_CURRENCY_KEYS = %i[unit format native_format].freeze
|
44
|
+
|
45
|
+
module_function
|
46
|
+
|
47
|
+
# Returns the primary locale for displaying a currency.
|
48
|
+
# If the user's locale matches a native locale for the currency, use it.
|
49
|
+
# Otherwise, use the first native locale for the currency.
|
50
|
+
#
|
51
|
+
# @param currency_code [String] ISO 4217 currency code (e.g., 'USD', 'EUR')
|
52
|
+
# @param user_locale [Symbol] User's preferred locale
|
53
|
+
# @return [Symbol] Primary locale for currency display
|
54
|
+
def primary_locale_for_currency(currency_code, user_locale)
|
55
|
+
locales = native_locales_for_currency(currency_code)
|
56
|
+
(locales.include?(user_locale.to_sym) ? user_locale.to_sym : locales.first) || :en
|
57
|
+
end
|
58
|
+
|
59
|
+
# Returns the locale to use for currency precision rules in standard formatting.
|
60
|
+
# Always returns the currency's primary native locale to ensure consistent
|
61
|
+
# precision (e.g., USD always shows 2 decimals, JPY shows 0 decimals).
|
62
|
+
#
|
63
|
+
# @param currency_code [String] ISO 4217 currency code
|
64
|
+
# @param user_locale [Symbol] User's preferred locale (for context)
|
65
|
+
# @return [Symbol] Locale for precision rules
|
66
|
+
def currency_precision_locale(currency_code, _user_locale = nil)
|
67
|
+
native_locales_for_currency(currency_code).first || :en
|
68
|
+
end
|
69
|
+
|
70
|
+
# Checks if a locale is considered native for a given currency.
|
71
|
+
#
|
72
|
+
# @param currency_code [String] ISO 4217 currency code
|
73
|
+
# @param locale [Symbol] Locale to check
|
74
|
+
# @return [Boolean] Whether the locale is native for the currency
|
75
|
+
def native_locale?(currency_code, locale)
|
76
|
+
return false unless currency_code && locale
|
77
|
+
|
78
|
+
native_locales_for_currency(currency_code).include?(locale.to_sym)
|
79
|
+
end
|
80
|
+
|
81
|
+
# Returns all native locales for a given currency.
|
82
|
+
#
|
83
|
+
# @param currency_code [String] ISO 4217 currency code
|
84
|
+
# @return [Array<Symbol>] Array of native locales for the currency
|
85
|
+
def native_locales_for_currency(currency_code)
|
86
|
+
currency_code = currency_code&.upcase
|
87
|
+
CURRENCY_NATIVE_LOCALES[currency_code] || []
|
88
|
+
end
|
89
|
+
|
90
|
+
# Generates the I18n key for native currency formatting.
|
91
|
+
#
|
92
|
+
# @param key [Symbol] Currency format key (:unit, :format, etc.)
|
93
|
+
# @return [String] I18n key for native currency formatting
|
94
|
+
def native_currency_key(key)
|
95
|
+
"number.currency.format.native_#{key}"
|
96
|
+
end
|
97
|
+
|
98
|
+
# Generates the I18n key for standard currency formatting.
|
99
|
+
#
|
100
|
+
# @param key [Symbol] Currency format key (:unit, :format, etc.)
|
101
|
+
# @return [String] I18n key for standard currency formatting
|
102
|
+
def currency_key(key)
|
103
|
+
"number.currency.format.#{key}"
|
104
|
+
end
|
105
|
+
|
106
|
+
# Looks up currency-related I18n values with native locale fallback.
|
107
|
+
#
|
108
|
+
# @param key [Symbol] Currency format key to look up
|
109
|
+
# @param locale [Symbol] Display locale
|
110
|
+
# @param currency_code [String] ISO 4217 currency code
|
111
|
+
# @return [String, nil] I18n value or nil if not found
|
112
|
+
def lookup_currency_i18n_value(key, locale:, currency_code:)
|
113
|
+
# Return nil for unknown currencies so formatters can fall back appropriately
|
114
|
+
return nil if native_locales_for_currency(currency_code).empty?
|
115
|
+
|
116
|
+
if SUPPORTED_NATIVE_CURRENCY_KEYS.include?(key) && native_locale?(currency_code, locale)
|
117
|
+
native_key = native_currency_key(key)
|
118
|
+
|
119
|
+
return I18n.t(native_key, locale:) if I18n.exists?(native_key, locale)
|
120
|
+
end
|
121
|
+
|
122
|
+
I18n.t(currency_key(key), locale: primary_locale_for_currency(currency_code, locale))
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
@@ -0,0 +1,98 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module HumanNumber
|
4
|
+
module Rails
|
5
|
+
# Rails view helpers for human-readable number and currency formatting.
|
6
|
+
#
|
7
|
+
# These helpers provide convenient access to HumanNumber functionality
|
8
|
+
# within Rails views and controllers with Rails-friendly parameter handling.
|
9
|
+
module Helpers
|
10
|
+
# Formats a number with intelligent, locale-aware abbreviations.
|
11
|
+
#
|
12
|
+
# This is a Rails helper wrapper around HumanNumber.human_number that provides
|
13
|
+
# Rails-friendly parameter handling with optional locale detection.
|
14
|
+
#
|
15
|
+
# @param number [Numeric] The number to format
|
16
|
+
# @param locale [Symbol, String, nil] The locale for formatting (default: current I18n locale)
|
17
|
+
# @param options [Hash] Additional formatting options
|
18
|
+
# @option options [Boolean] :abbr_units (true) Use abbreviated unit symbols
|
19
|
+
# @option options [Integer, nil] :max_digits (1) Maximum significant digits
|
20
|
+
# @option options [Integer, nil] :min_unit (nil) Minimum unit threshold
|
21
|
+
# @option options [Boolean] :trim_zeros (true) Remove trailing decimal zeros
|
22
|
+
#
|
23
|
+
# @return [String] The formatted number string
|
24
|
+
#
|
25
|
+
# @example Basic usage in views
|
26
|
+
# <%= human_number(1234567) %> #=> "1.2M"
|
27
|
+
# <%= human_number(50000, locale: :ko) %> #=> "5만"
|
28
|
+
# <%= human_number(1234567, max_digits: 2) %> #=> "1.23M"
|
29
|
+
#
|
30
|
+
# @see HumanNumber.human_number Main implementation
|
31
|
+
def human_number(number, locale: nil, **options)
|
32
|
+
locale ||= I18n.locale
|
33
|
+
HumanNumber.human_number(number, locale:, **options)
|
34
|
+
end
|
35
|
+
|
36
|
+
# Formats a currency amount using standard Rails currency formatting.
|
37
|
+
#
|
38
|
+
# This helper provides Rails-friendly parameter handling for currency formatting
|
39
|
+
# with automatic locale detection and parameter normalization.
|
40
|
+
#
|
41
|
+
# @param number [Numeric] The amount to format
|
42
|
+
# @param currency_code [String] ISO 4217 currency code (e.g., 'USD', 'EUR')
|
43
|
+
# @param locale [Symbol, String, nil] Display locale (default: current I18n locale)
|
44
|
+
#
|
45
|
+
# @return [String] The formatted currency string
|
46
|
+
#
|
47
|
+
# @example Basic usage in views
|
48
|
+
# <%= currency(1234.56, currency_code: 'USD') %> #=> "$1,234.56"
|
49
|
+
# <%= currency(50000, currency_code: 'KRW', locale: :ko) %> #=> "50,000원"
|
50
|
+
# <%= currency(1234.99, currency_code: 'JPY') %> #=> "1,235円"
|
51
|
+
#
|
52
|
+
# @see HumanNumber.currency Main implementation
|
53
|
+
def currency(number, currency_code:, locale: nil)
|
54
|
+
locale ||= I18n.locale
|
55
|
+
HumanNumber.currency(number, currency_code:, locale:)
|
56
|
+
end
|
57
|
+
|
58
|
+
# Formats a currency amount with intelligent, locale-aware abbreviations.
|
59
|
+
#
|
60
|
+
# This helper combines human-readable number formatting with currency symbols,
|
61
|
+
# providing Rails-friendly access to abbreviated currency formatting.
|
62
|
+
#
|
63
|
+
# @param number [Numeric] The amount to format
|
64
|
+
# @param currency_code [String] ISO 4217 currency code (e.g., 'USD', 'EUR')
|
65
|
+
# @param locale [Symbol, String, nil] Locale for formatting (default: current I18n locale)
|
66
|
+
# @param options [Hash] Additional formatting options
|
67
|
+
# @option options [Boolean] :abbr_units (true) Use abbreviated unit symbols
|
68
|
+
# @option options [Integer, nil] :max_digits (1) Maximum significant digits
|
69
|
+
# @option options [Integer, nil] :min_unit (nil) Minimum unit threshold
|
70
|
+
# @option options [Boolean] :trim_zeros (true) Remove trailing decimal zeros
|
71
|
+
#
|
72
|
+
# @return [String] The formatted currency string with abbreviations
|
73
|
+
#
|
74
|
+
# @example Basic usage in views
|
75
|
+
# <%= human_currency(1234567, currency_code: 'USD') %> #=> "$1.2M"
|
76
|
+
# <%= human_currency(50000, currency_code: 'KRW', locale: :ko) %> #=> "5만원"
|
77
|
+
# <%= human_currency(1234567, currency_code: 'USD', max_digits: 2) %> #=> "$1.23M"
|
78
|
+
# <%= human_currency(1000000, currency_code: 'USD', abbr_units: false) %> #=> "$1 million"
|
79
|
+
#
|
80
|
+
# @see HumanNumber.human_currency Main implementation
|
81
|
+
def human_currency(number, currency_code:, locale: nil, **options)
|
82
|
+
locale ||= I18n.locale
|
83
|
+
HumanNumber.human_currency(number, currency_code:, locale:, **options)
|
84
|
+
end
|
85
|
+
|
86
|
+
# Legacy alias for human_number to maintain backward compatibility.
|
87
|
+
#
|
88
|
+
# @deprecated Use {#human_number} instead
|
89
|
+
# @param number [Numeric] The number to format
|
90
|
+
# @param options [Hash] Formatting options (Rails-style hash)
|
91
|
+
# @return [String] The formatted number string
|
92
|
+
def number_to_human_size(number, **options)
|
93
|
+
locale = options.delete(:locale) || I18n.locale
|
94
|
+
human_number(number, locale:, **options)
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|