human_number 0.1.10 → 0.2.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 +4 -4
- data/CHANGELOG.md +34 -99
- data/README.md +97 -4
- data/lib/human_number/formatters/number.rb +10 -397
- data/lib/human_number/number_system.rb +657 -0
- data/lib/human_number/version.rb +1 -1
- data/lib/human_number.rb +65 -0
- metadata +2 -1
@@ -0,0 +1,657 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "action_view"
|
4
|
+
require "i18n"
|
5
|
+
|
6
|
+
module HumanNumber
|
7
|
+
# Number system determination and formatting for international standards compliance.
|
8
|
+
#
|
9
|
+
# This module provides the core logic for determining which number formatting system
|
10
|
+
# to use based on locale or currency context. It supports three distinct number
|
11
|
+
# systems that align with different cultural and linguistic number naming conventions:
|
12
|
+
#
|
13
|
+
# * **Default System** (Western): thousand, million, billion, trillion (K/M/B/T)
|
14
|
+
# * **East Asian System**: ten thousand (만/万), hundred million (억/億), trillion (조/兆)
|
15
|
+
# * **Indian System**: thousand, lakh, crore
|
16
|
+
#
|
17
|
+
# The module automatically maps locales and currencies to their culturally
|
18
|
+
# appropriate number system, ensuring that formatted numbers follow the
|
19
|
+
# conventions expected by users in different regions.
|
20
|
+
#
|
21
|
+
# @example Determining number system by locale
|
22
|
+
# HumanNumber::NumberSystem.number_system(locale: :en) #=> :default
|
23
|
+
# HumanNumber::NumberSystem.number_system(locale: :ko) #=> :east_asian
|
24
|
+
# HumanNumber::NumberSystem.number_system(locale: :hi) #=> :indian
|
25
|
+
#
|
26
|
+
# @example Determining number system by currency
|
27
|
+
# HumanNumber::NumberSystem.currency_number_system(currency_code: 'USD') #=> :default
|
28
|
+
# HumanNumber::NumberSystem.currency_number_system(currency_code: 'KRW') #=> :east_asian
|
29
|
+
# HumanNumber::NumberSystem.currency_number_system(currency_code: 'INR') #=> :indian
|
30
|
+
#
|
31
|
+
# @see HumanNumber.number_system
|
32
|
+
# @see HumanNumber.currency_number_system
|
33
|
+
# @since 0.1.10
|
34
|
+
module NumberSystem
|
35
|
+
# Currency-to-number-system mapping for culturally appropriate formatting.
|
36
|
+
#
|
37
|
+
# Maps ISO 4217 currency codes to their regionally appropriate number
|
38
|
+
# formatting systems. Currencies not listed default to the Western system.
|
39
|
+
#
|
40
|
+
# @example East Asian currencies (powers of 10,000 progression)
|
41
|
+
# 'KRW' => Korean Won, 'JPY' => Japanese Yen, 'CNY' => Chinese Yuan
|
42
|
+
# 'SGD' => Singapore Dollar, 'HKD' => Hong Kong Dollar
|
43
|
+
#
|
44
|
+
# @example Indian subcontinent currencies (lakh/crore system)
|
45
|
+
# 'INR' => Indian Rupee
|
46
|
+
#
|
47
|
+
# @see currency_number_system
|
48
|
+
CURRENCY_SYSTEM_MAPPING = {
|
49
|
+
east_asian: %w[KRW JPY CNY SGD HKD].freeze,
|
50
|
+
indian: %w[INR].freeze,
|
51
|
+
# All other currencies use the default system (K/M/B/T)
|
52
|
+
}.freeze
|
53
|
+
|
54
|
+
# Locale-to-number-system mapping for linguistic and cultural consistency.
|
55
|
+
#
|
56
|
+
# Maps locale identifiers to their culturally appropriate number formatting
|
57
|
+
# systems, considering how speakers of each language conceptualize and
|
58
|
+
# express large numbers. Locales not listed default to the Western system.
|
59
|
+
#
|
60
|
+
# @example East Asian locales (万/億/兆 or 만/억/조 progression)
|
61
|
+
# :ko => Korean, :ja => Japanese, :zh => Chinese (generic)
|
62
|
+
# :'zh-CN' => Chinese (Simplified), :'zh-TW' => Chinese (Traditional)
|
63
|
+
#
|
64
|
+
# @example Indian subcontinent locales (thousand/lakh/crore progression)
|
65
|
+
# :hi => Hindi, :ur => Urdu, :bn => Bengali, :'en-IN' => Indian English
|
66
|
+
#
|
67
|
+
# @see number_system
|
68
|
+
LOCALE_SYSTEM_MAPPING = {
|
69
|
+
east_asian: %i[ko ja zh zh-CN zh-TW].freeze,
|
70
|
+
indian: %i[hi ur bn en-IN].freeze,
|
71
|
+
# All other locales use the default system (K/M/B/T)
|
72
|
+
}.freeze
|
73
|
+
|
74
|
+
module_function
|
75
|
+
|
76
|
+
# Determines the appropriate number system for a given locale.
|
77
|
+
#
|
78
|
+
# This method maps locale codes to their culturally appropriate number
|
79
|
+
# formatting system. It considers regional numbering conventions and
|
80
|
+
# linguistic patterns to select the most suitable system.
|
81
|
+
#
|
82
|
+
# @param locale [Symbol, String, nil] The locale code to evaluate.
|
83
|
+
# When nil, defaults to the current I18n.locale setting.
|
84
|
+
# Accepts both symbol and string formats (e.g., :ko or 'ko').
|
85
|
+
#
|
86
|
+
# @return [Symbol] The number system identifier:
|
87
|
+
# * `:default` - Western system using K/M/B/T (thousand/million/billion/trillion)
|
88
|
+
# * `:east_asian` - Asian system using 만/억/조 or 万/億/兆 patterns
|
89
|
+
# * `:indian` - South Asian system using thousand/lakh/crore
|
90
|
+
#
|
91
|
+
# @example Basic locale determination
|
92
|
+
# NumberSystem.number_system(locale: :en) #=> :default
|
93
|
+
# NumberSystem.number_system(locale: :ko) #=> :east_asian
|
94
|
+
# NumberSystem.number_system(locale: :hi) #=> :indian
|
95
|
+
#
|
96
|
+
# @example Regional locale variants
|
97
|
+
# NumberSystem.number_system(locale: :'zh-CN') #=> :east_asian
|
98
|
+
# NumberSystem.number_system(locale: :'zh-TW') #=> :east_asian
|
99
|
+
# NumberSystem.number_system(locale: :'en-IN') #=> :indian
|
100
|
+
#
|
101
|
+
# @example String input and nil handling
|
102
|
+
# NumberSystem.number_system(locale: 'ja') #=> :east_asian
|
103
|
+
# NumberSystem.number_system(locale: nil) #=> (uses I18n.locale)
|
104
|
+
#
|
105
|
+
# @example Unknown locales default to Western system
|
106
|
+
# NumberSystem.number_system(locale: :unknown) #=> :default
|
107
|
+
# NumberSystem.number_system(locale: :xyz) #=> :default
|
108
|
+
#
|
109
|
+
# @see LOCALE_SYSTEM_MAPPING
|
110
|
+
# @since 0.1.10
|
111
|
+
def number_system(locale: I18n.locale)
|
112
|
+
locale = (locale || I18n.locale).to_sym
|
113
|
+
|
114
|
+
LOCALE_SYSTEM_MAPPING.each do |system, locales|
|
115
|
+
return system if locales.include?(locale)
|
116
|
+
end
|
117
|
+
|
118
|
+
:default
|
119
|
+
end
|
120
|
+
|
121
|
+
# Determines the appropriate number system for a given currency code.
|
122
|
+
#
|
123
|
+
# This method maps ISO 4217 currency codes to their culturally and
|
124
|
+
# geographically appropriate number formatting systems. It considers
|
125
|
+
# the regional context where the currency is primarily used and the
|
126
|
+
# number formatting conventions of those regions.
|
127
|
+
#
|
128
|
+
# @param currency_code [String, nil] The ISO 4217 currency code to evaluate.
|
129
|
+
# Expected format is 3-letter uppercase (e.g., 'USD', 'EUR', 'KRW').
|
130
|
+
# Case-insensitive and handles whitespace automatically.
|
131
|
+
# When nil, returns `:default`.
|
132
|
+
#
|
133
|
+
# @return [Symbol] The number system identifier:
|
134
|
+
# * `:default` - Western system for most global currencies (USD, EUR, GBP, etc.)
|
135
|
+
# * `:east_asian` - Asian system for East Asian currencies (KRW, JPY, CNY, etc.)
|
136
|
+
# * `:indian` - South Asian system for Indian subcontinent currencies (INR)
|
137
|
+
#
|
138
|
+
# @example Major currency mappings
|
139
|
+
# NumberSystem.currency_number_system(currency_code: 'USD') #=> :default
|
140
|
+
# NumberSystem.currency_number_system(currency_code: 'EUR') #=> :default
|
141
|
+
# NumberSystem.currency_number_system(currency_code: 'KRW') #=> :east_asian
|
142
|
+
# NumberSystem.currency_number_system(currency_code: 'JPY') #=> :east_asian
|
143
|
+
# NumberSystem.currency_number_system(currency_code: 'INR') #=> :indian
|
144
|
+
#
|
145
|
+
# @example East Asian currencies
|
146
|
+
# NumberSystem.currency_number_system(currency_code: 'CNY') #=> :east_asian # Chinese Yuan
|
147
|
+
# NumberSystem.currency_number_system(currency_code: 'SGD') #=> :east_asian # Singapore Dollar
|
148
|
+
# NumberSystem.currency_number_system(currency_code: 'HKD') #=> :east_asian # Hong Kong Dollar
|
149
|
+
#
|
150
|
+
# @example Case handling and whitespace
|
151
|
+
# NumberSystem.currency_number_system(currency_code: 'krw') #=> :east_asian # lowercase
|
152
|
+
# NumberSystem.currency_number_system(currency_code: ' USD ') #=> :default # with spaces
|
153
|
+
# NumberSystem.currency_number_system(currency_code: 'KrW') #=> :east_asian # mixed case
|
154
|
+
#
|
155
|
+
# @example Edge cases and unknown currencies
|
156
|
+
# NumberSystem.currency_number_system(currency_code: nil) #=> :default
|
157
|
+
# NumberSystem.currency_number_system(currency_code: '') #=> :default
|
158
|
+
# NumberSystem.currency_number_system(currency_code: 'XYZ') #=> :default # unknown
|
159
|
+
#
|
160
|
+
# @see CURRENCY_SYSTEM_MAPPING
|
161
|
+
# @see https://en.wikipedia.org/wiki/ISO_4217
|
162
|
+
# @since 0.1.10
|
163
|
+
def currency_number_system(currency_code:)
|
164
|
+
return :default unless currency_code
|
165
|
+
|
166
|
+
currency_code = currency_code.upcase.strip
|
167
|
+
|
168
|
+
CURRENCY_SYSTEM_MAPPING.each do |system, currencies|
|
169
|
+
return system if currencies.include?(currency_code)
|
170
|
+
end
|
171
|
+
|
172
|
+
:default
|
173
|
+
end
|
174
|
+
end
|
175
|
+
|
176
|
+
# Constants for string literals and magic numbers used by system classes
|
177
|
+
ZERO_STRING = "0"
|
178
|
+
EMPTY_SEPARATOR = ""
|
179
|
+
SPACE_SEPARATOR = " "
|
180
|
+
DECIMAL_FORMAT_TEMPLATE = "%.%df"
|
181
|
+
MINIMUM_UNIT_COUNT = 1.0
|
182
|
+
SINGLE_DIGIT_LIMIT = 1
|
183
|
+
|
184
|
+
# I18n key templates
|
185
|
+
I18N_DECIMAL_UNITS_KEY = "number.human.decimal_units"
|
186
|
+
I18N_ABBR_UNITS_SECTION = "abbr_units"
|
187
|
+
I18N_UNITS_SECTION = "units"
|
188
|
+
|
189
|
+
module NumberSystem
|
190
|
+
# Western/Default number formatting system using standard thousand-based units.
|
191
|
+
#
|
192
|
+
# This system implements the standard Western number formatting convention
|
193
|
+
# using powers of 1000 progression: thousand, million, billion, trillion.
|
194
|
+
# It's used by most Western languages and global financial contexts.
|
195
|
+
#
|
196
|
+
# @example Formatting examples
|
197
|
+
# # Via HumanNumber.human_number (using :en locale)
|
198
|
+
# HumanNumber.human_number(1_234) #=> "1K 234"
|
199
|
+
# HumanNumber.human_number(1_234_567) #=> "1M 234K 567"
|
200
|
+
# HumanNumber.human_number(1_234_567_890) #=> "1B 234M 567K 890"
|
201
|
+
#
|
202
|
+
# # Abbreviated mode
|
203
|
+
# HumanNumber.human_number(1_234_567, max_digits: 2) #=> "1.2M"
|
204
|
+
#
|
205
|
+
# @example Supported locales
|
206
|
+
# # European languages: English, German, French, Spanish, Italian, etc.
|
207
|
+
# [:en, :de, :fr, :es, :it, :pt, :ru, :nl, :sv, :da, :no]
|
208
|
+
#
|
209
|
+
# @see UNIT_DEFINITIONS
|
210
|
+
# @since 0.1.10
|
211
|
+
class DefaultSystem
|
212
|
+
extend ActionView::Helpers::NumberHelper
|
213
|
+
|
214
|
+
UNIT_DEFINITIONS = [
|
215
|
+
{ key: :trillion, divisor: 1_000_000_000_000 },
|
216
|
+
{ key: :billion, divisor: 1_000_000_000 },
|
217
|
+
{ key: :million, divisor: 1_000_000 },
|
218
|
+
{ key: :thousand, divisor: 1_000 },
|
219
|
+
].freeze
|
220
|
+
|
221
|
+
class << self
|
222
|
+
def format_number(number, locale:, abbr_units: true, min_unit: nil, max_digits: 2, trim_zeros: true)
|
223
|
+
return ZERO_STRING if number.zero?
|
224
|
+
|
225
|
+
# Check if number meets minimum unit threshold for human formatting
|
226
|
+
return format_below_min_unit(number, locale) if min_unit && number.abs < min_unit
|
227
|
+
|
228
|
+
parts = if max_digits.nil?
|
229
|
+
format_in_complete_mode(number, locale, abbr_units, min_unit)
|
230
|
+
else
|
231
|
+
format_in_abbreviated_mode(number, locale, abbr_units, max_digits, trim_zeros, min_unit)
|
232
|
+
end
|
233
|
+
|
234
|
+
return number.to_s if parts.nil?
|
235
|
+
|
236
|
+
finalize_result(number, parts)
|
237
|
+
end
|
238
|
+
|
239
|
+
def format_in_complete_mode(number, locale, abbr_units, _min_unit)
|
240
|
+
# Complete mode shows all units: "1M 234K 567"
|
241
|
+
unit_breakdown = break_down_into_all_units(number)
|
242
|
+
return nil if unit_breakdown.empty?
|
243
|
+
|
244
|
+
format_all_unit_parts(unit_breakdown, locale, abbr_units)
|
245
|
+
end
|
246
|
+
|
247
|
+
def format_in_abbreviated_mode(number, locale, abbr_units, max_digits, trim_zeros, _min_unit)
|
248
|
+
# Abbreviated mode shows single largest unit: "1.2M"
|
249
|
+
unit_breakdown = break_down_into_largest_unit(number)
|
250
|
+
return nil if unit_breakdown.empty?
|
251
|
+
|
252
|
+
format_abbreviated_unit_parts(unit_breakdown, locale, abbr_units, max_digits, trim_zeros)
|
253
|
+
end
|
254
|
+
|
255
|
+
# Format numbers below min_unit threshold with locale-appropriate delimiters
|
256
|
+
def format_below_min_unit(number, locale)
|
257
|
+
number_with_delimiter(number, locale: locale) || number.to_s
|
258
|
+
end
|
259
|
+
|
260
|
+
private
|
261
|
+
|
262
|
+
def breakdown_number(number)
|
263
|
+
result = []
|
264
|
+
remaining = number.abs
|
265
|
+
|
266
|
+
unit_definitions.each do |unit|
|
267
|
+
next unless remaining >= unit[:divisor]
|
268
|
+
|
269
|
+
count = remaining.to_f / unit[:divisor]
|
270
|
+
result << { unit_key: unit[:key], count: count, divisor: unit[:divisor] }
|
271
|
+
break # Only use the largest applicable unit
|
272
|
+
end
|
273
|
+
|
274
|
+
# If no unit matched, use the raw number
|
275
|
+
result << { unit_key: :units, count: remaining, divisor: 1 } if result.empty?
|
276
|
+
|
277
|
+
result
|
278
|
+
end
|
279
|
+
|
280
|
+
# Find the largest applicable unit for abbreviated display
|
281
|
+
# Example: 1,234,567 -> [{million: 1.234567}]
|
282
|
+
def break_down_into_largest_unit(number)
|
283
|
+
absolute_amount = number.abs
|
284
|
+
|
285
|
+
# Find the first (largest) unit where the count would be >= 1.0
|
286
|
+
unit_definitions.each do |unit|
|
287
|
+
unit_count = absolute_amount.to_f / unit[:divisor]
|
288
|
+
|
289
|
+
return [create_unit_part(unit[:key], unit_count, unit[:divisor])] if unit_count >= MINIMUM_UNIT_COUNT
|
290
|
+
end
|
291
|
+
|
292
|
+
# If no unit is applicable, return the raw number
|
293
|
+
[create_unit_part(:units, absolute_amount, 1)]
|
294
|
+
end
|
295
|
+
|
296
|
+
def unit_definitions
|
297
|
+
UNIT_DEFINITIONS
|
298
|
+
end
|
299
|
+
|
300
|
+
# Helper method to create consistent unit part structure
|
301
|
+
def create_unit_part(unit_key, count, divisor)
|
302
|
+
{ unit_key: unit_key, count: count, divisor: divisor }
|
303
|
+
end
|
304
|
+
|
305
|
+
# Filter out units below the minimum threshold
|
306
|
+
def filter_by_minimum_unit_threshold(breakdown, min_unit)
|
307
|
+
return breakdown unless min_unit
|
308
|
+
|
309
|
+
breakdown.select { |unit_info| unit_info[:divisor] >= min_unit }
|
310
|
+
end
|
311
|
+
|
312
|
+
# Format unit parts for abbreviated display (e.g., "1.2M")
|
313
|
+
def format_abbreviated_unit_parts(breakdown, locale, abbr_units, max_digits, trim_zeros)
|
314
|
+
# Check if we're in human formatting mode (any non-:units parts exist)
|
315
|
+
human_formatting_applied = breakdown.any? { |unit_info| unit_info[:unit_key] != :units }
|
316
|
+
|
317
|
+
breakdown.map do |unit_info|
|
318
|
+
if unit_info[:unit_key] == :units
|
319
|
+
if human_formatting_applied
|
320
|
+
# Part of human formatting - no delimiters
|
321
|
+
unit_info[:count].to_s
|
322
|
+
else
|
323
|
+
# Numbers below minimum unit threshold - apply locale formatting
|
324
|
+
format_below_min_unit(unit_info[:count], locale)
|
325
|
+
end
|
326
|
+
else
|
327
|
+
# Format with unit symbol (e.g., "1.2" + "M" = "1.2M")
|
328
|
+
format_number_with_unit_symbol(unit_info, locale, abbr_units, max_digits, trim_zeros)
|
329
|
+
end
|
330
|
+
end
|
331
|
+
end
|
332
|
+
|
333
|
+
# Format raw number without unit symbols
|
334
|
+
def format_raw_number_count(count, max_digits, trim_zeros)
|
335
|
+
format_unit_count(count, max_digits:, trim_zeros:, apply_rounding: false)
|
336
|
+
end
|
337
|
+
|
338
|
+
# Format number with unit symbol (e.g., "1.2M")
|
339
|
+
def format_number_with_unit_symbol(unit_info, locale, abbr_units, max_digits, trim_zeros)
|
340
|
+
symbol = lookup_unit_symbol(locale, unit_info[:unit_key], abbr_units)
|
341
|
+
format_with_symbol(unit_info[:count], symbol, abbr_units, max_digits:, trim_zeros:)
|
342
|
+
end
|
343
|
+
|
344
|
+
# Format unit with symbol for complete mode (e.g., "234K")
|
345
|
+
def format_complete_unit_with_symbol(unit_info, locale, abbr_units)
|
346
|
+
symbol = lookup_unit_symbol(locale, unit_info[:unit_key], abbr_units)
|
347
|
+
count_text = unit_info[:count].to_i.to_s
|
348
|
+
separator = determine_unit_value_separator(abbr_units)
|
349
|
+
formatted_symbol = apply_symbol_formatting_rules(symbol, abbr_units)
|
350
|
+
"#{count_text}#{separator}#{formatted_symbol}"
|
351
|
+
end
|
352
|
+
|
353
|
+
def format_unit_count(count, max_digits: 2, trim_zeros: true, apply_rounding: true)
|
354
|
+
return count.to_s unless count.is_a?(Numeric)
|
355
|
+
|
356
|
+
rounded = apply_rounding ? round_to_significant_digits(count, max_digits) : count
|
357
|
+
|
358
|
+
format_rounded_count(rounded, max_digits, trim_zeros, apply_rounding, count)
|
359
|
+
end
|
360
|
+
|
361
|
+
def format_rounded_count(rounded, max_digits, trim_zeros, apply_rounding, original_count)
|
362
|
+
if trim_zeros
|
363
|
+
format_with_trimmed_zeros(rounded)
|
364
|
+
elsif apply_rounding
|
365
|
+
format_with_fixed_decimals(rounded, max_digits)
|
366
|
+
else
|
367
|
+
original_count.to_s
|
368
|
+
end
|
369
|
+
end
|
370
|
+
|
371
|
+
def format_with_trimmed_zeros(rounded)
|
372
|
+
if rounded == rounded.to_i
|
373
|
+
rounded.to_i.to_s
|
374
|
+
else
|
375
|
+
rounded.to_s
|
376
|
+
end
|
377
|
+
end
|
378
|
+
|
379
|
+
def format_with_fixed_decimals(rounded, max_digits)
|
380
|
+
integer_digits = rounded.to_i.to_s.length
|
381
|
+
decimal_places = [0, max_digits - integer_digits].max
|
382
|
+
format("%.#{decimal_places}f", rounded)
|
383
|
+
end
|
384
|
+
|
385
|
+
def round_to_significant_digits(number, digits)
|
386
|
+
return handle_edge_cases(number, digits) if should_handle_edge_case?(number, digits)
|
387
|
+
|
388
|
+
abs_num = number.abs
|
389
|
+
|
390
|
+
# Simple significant digits rounding using scientific notation
|
391
|
+
magnitude = Math.log10(abs_num).floor
|
392
|
+
scale_factor = 10**(digits - 1 - magnitude)
|
393
|
+
|
394
|
+
rounded = (abs_num * scale_factor).round / scale_factor.to_f
|
395
|
+
apply_sign(number, rounded)
|
396
|
+
end
|
397
|
+
|
398
|
+
def should_handle_edge_case?(number, digits)
|
399
|
+
number.zero? || digits <= 0
|
400
|
+
end
|
401
|
+
|
402
|
+
def handle_edge_cases(number, _digits)
|
403
|
+
return 0.0 if number.zero?
|
404
|
+
|
405
|
+
number
|
406
|
+
end
|
407
|
+
|
408
|
+
def apply_sign(original_number, result)
|
409
|
+
original_number.negative? ? -result : result
|
410
|
+
end
|
411
|
+
|
412
|
+
def count_integer_digits(int_part)
|
413
|
+
int_part.zero? ? SINGLE_DIGIT_LIMIT : int_part.to_s.length
|
414
|
+
end
|
415
|
+
|
416
|
+
# Look up the localized symbol for a unit (e.g., 'M' for million)
|
417
|
+
def lookup_unit_symbol(locale, unit_key, abbr_units)
|
418
|
+
section = abbr_units ? I18N_ABBR_UNITS_SECTION : I18N_UNITS_SECTION
|
419
|
+
|
420
|
+
I18n.t("#{I18N_DECIMAL_UNITS_KEY}.#{section}.#{unit_key}", locale:, default: nil) ||
|
421
|
+
I18n.t("#{I18N_DECIMAL_UNITS_KEY}.#{section}.#{unit_key}", locale: :en, default: nil) ||
|
422
|
+
unit_key.to_s.upcase
|
423
|
+
end
|
424
|
+
|
425
|
+
def format_with_symbol(value, symbol, abbr_units, max_digits: 2, trim_zeros: true)
|
426
|
+
# Apply significant digits rounding to unit values
|
427
|
+
formatted_count = format_unit_count(value, max_digits:, trim_zeros:, apply_rounding: true)
|
428
|
+
separator = determine_unit_value_separator(abbr_units)
|
429
|
+
formatted_symbol = apply_symbol_formatting_rules(symbol, abbr_units)
|
430
|
+
"#{formatted_count}#{separator}#{formatted_symbol}"
|
431
|
+
end
|
432
|
+
|
433
|
+
# Determine separator between number and unit ("1M" vs "1 million")
|
434
|
+
def determine_unit_value_separator(abbr_units)
|
435
|
+
abbr_units ? EMPTY_SEPARATOR : SPACE_SEPARATOR
|
436
|
+
end
|
437
|
+
|
438
|
+
# Apply locale-specific formatting rules to unit symbols
|
439
|
+
def apply_symbol_formatting_rules(symbol, abbr_units)
|
440
|
+
return symbol if abbr_units || symbol.length <= 1
|
441
|
+
|
442
|
+
symbol.downcase # Western lowercase rule for full words
|
443
|
+
end
|
444
|
+
|
445
|
+
# Break down number into all applicable units for complete display
|
446
|
+
# Example: 1,234,567 -> [{million: 1}, {thousand: 234}, {units: 567}]
|
447
|
+
def break_down_into_all_units(number)
|
448
|
+
unit_breakdown = UnitBreakdown.new(number.abs, unit_definitions)
|
449
|
+
unit_breakdown.extract_all_units
|
450
|
+
end
|
451
|
+
|
452
|
+
# Helper class to manage unit breakdown state
|
453
|
+
class UnitBreakdown
|
454
|
+
def initialize(amount, unit_definitions)
|
455
|
+
@remaining_amount = amount
|
456
|
+
@unit_definitions = unit_definitions
|
457
|
+
@unit_parts = []
|
458
|
+
end
|
459
|
+
|
460
|
+
def extract_all_units
|
461
|
+
extract_major_units
|
462
|
+
add_remaining_as_units
|
463
|
+
@unit_parts
|
464
|
+
end
|
465
|
+
|
466
|
+
private
|
467
|
+
|
468
|
+
def extract_major_units
|
469
|
+
@unit_definitions.each do |unit|
|
470
|
+
extract_single_unit(unit) if @remaining_amount >= unit[:divisor]
|
471
|
+
end
|
472
|
+
end
|
473
|
+
|
474
|
+
def extract_single_unit(unit)
|
475
|
+
unit_count = (@remaining_amount / unit[:divisor]).to_i
|
476
|
+
return if unit_count.zero?
|
477
|
+
|
478
|
+
@unit_parts << create_unit_part(unit[:key], unit_count, unit[:divisor])
|
479
|
+
@remaining_amount %= unit[:divisor]
|
480
|
+
end
|
481
|
+
|
482
|
+
def add_remaining_as_units
|
483
|
+
return unless @remaining_amount.positive?
|
484
|
+
|
485
|
+
@unit_parts << create_unit_part(:units, @remaining_amount, 1)
|
486
|
+
end
|
487
|
+
|
488
|
+
def create_unit_part(unit_key, count, divisor)
|
489
|
+
{ unit_key: unit_key, count: count, divisor: divisor }
|
490
|
+
end
|
491
|
+
end
|
492
|
+
|
493
|
+
# Format unit parts for complete display (e.g., "1M 234K 567")
|
494
|
+
def format_all_unit_parts(breakdown, locale, abbr_units)
|
495
|
+
# Check if we're in human formatting mode (any non-:units parts exist)
|
496
|
+
human_formatting_applied = breakdown.any? { |unit_info| unit_info[:unit_key] != :units }
|
497
|
+
|
498
|
+
breakdown.map do |unit_info|
|
499
|
+
if unit_info[:unit_key] == :units
|
500
|
+
if human_formatting_applied
|
501
|
+
# Part of human formatting - no delimiters
|
502
|
+
unit_info[:count].to_i.to_s
|
503
|
+
else
|
504
|
+
# Numbers below minimum unit threshold - apply locale formatting
|
505
|
+
format_below_min_unit(unit_info[:count].to_i, locale)
|
506
|
+
end
|
507
|
+
else
|
508
|
+
# Format each unit part with its symbol
|
509
|
+
format_complete_unit_with_symbol(unit_info, locale, abbr_units)
|
510
|
+
end
|
511
|
+
end
|
512
|
+
end
|
513
|
+
|
514
|
+
# Apply sign and join all parts into final result
|
515
|
+
def finalize_result(number, formatted_parts)
|
516
|
+
combined_result = formatted_parts.join(SPACE_SEPARATOR)
|
517
|
+
number.negative? ? "-#{combined_result}" : combined_result
|
518
|
+
end
|
519
|
+
end
|
520
|
+
end
|
521
|
+
|
522
|
+
# East Asian number formatting system using traditional Asian unit progression.
|
523
|
+
#
|
524
|
+
# This system implements the traditional East Asian number formatting convention
|
525
|
+
# using powers of 10,000 (万) progression for mid-range numbers, then jumping
|
526
|
+
# to 100,000,000 (억/億) and 1,000,000,000,000 (조/兆). This aligns with how
|
527
|
+
# numbers are conceptualized and spoken in Korean, Japanese, and Chinese.
|
528
|
+
#
|
529
|
+
# The system uses culturally appropriate separators and maintains the original
|
530
|
+
# case for Asian characters, unlike the Western system which lowercases full words.
|
531
|
+
#
|
532
|
+
# @example Formatting examples
|
533
|
+
# # Korean (만/억/조)
|
534
|
+
# HumanNumber.human_number(12_345, locale: :ko) #=> "1만 2345"
|
535
|
+
# HumanNumber.human_number(123_456_789, locale: :ko) #=> "1억 2345만 6789"
|
536
|
+
#
|
537
|
+
# # Japanese (万/億/兆)
|
538
|
+
# HumanNumber.human_number(12_345, locale: :ja) #=> "1万 2345"
|
539
|
+
#
|
540
|
+
# # Chinese (万/億/兆)
|
541
|
+
# HumanNumber.human_number(12_345, locale: :'zh-CN') #=> "1万 2345"
|
542
|
+
#
|
543
|
+
# # Abbreviated mode
|
544
|
+
# HumanNumber.human_number(123_456_789, locale: :ko, max_digits: 2) #=> "1.2억"
|
545
|
+
#
|
546
|
+
# @example Supported locales and currencies
|
547
|
+
# # Locales: [:ko, :ja, :zh, :'zh-CN', :'zh-TW']
|
548
|
+
# # Currencies: ['KRW', 'JPY', 'CNY', 'SGD', 'HKD']
|
549
|
+
#
|
550
|
+
# @example Cultural separators
|
551
|
+
# # Uses locale-specific separators (no space for some Asian languages)
|
552
|
+
# HumanNumber.human_number(12_345_678, locale: :ko) #=> "1234만 5678"
|
553
|
+
#
|
554
|
+
# @see UNIT_DEFINITIONS
|
555
|
+
# @see EastAsianSystem#finalize_result
|
556
|
+
# @since 0.1.10
|
557
|
+
class EastAsianSystem < DefaultSystem
|
558
|
+
UNIT_DEFINITIONS = [
|
559
|
+
{ key: :trillion, divisor: 1_000_000_000_000 },
|
560
|
+
{ key: :hundred_million, divisor: 100_000_000 },
|
561
|
+
{ key: :ten_thousand, divisor: 10_000 },
|
562
|
+
].freeze
|
563
|
+
|
564
|
+
class << self
|
565
|
+
def format_number(number, locale:, abbr_units: true, min_unit: nil, max_digits: 2, trim_zeros: true)
|
566
|
+
@current_locale = locale.to_sym
|
567
|
+
super
|
568
|
+
end
|
569
|
+
|
570
|
+
private
|
571
|
+
|
572
|
+
def unit_definitions
|
573
|
+
UNIT_DEFINITIONS
|
574
|
+
end
|
575
|
+
|
576
|
+
def format_with_symbol(value, symbol, _abbr_units, max_digits: 2, trim_zeros: true)
|
577
|
+
# East Asian: no separator regardless of abbr_units
|
578
|
+
# Apply significant digits rounding to unit values
|
579
|
+
formatted_count = format_unit_count(value, max_digits:, trim_zeros:, apply_rounding: true)
|
580
|
+
formatted_symbol = apply_symbol_formatting_rules(symbol, true)
|
581
|
+
"#{formatted_count}#{formatted_symbol}"
|
582
|
+
end
|
583
|
+
|
584
|
+
# Keep original case for Asian characters (만, 억, 조)
|
585
|
+
def apply_symbol_formatting_rules(symbol, _abbr_units)
|
586
|
+
symbol
|
587
|
+
end
|
588
|
+
|
589
|
+
# Apply culturally appropriate separators for East Asian languages
|
590
|
+
def finalize_result(number, formatted_parts)
|
591
|
+
separator = lookup_decimal_separator(@current_locale)
|
592
|
+
combined_result = formatted_parts.join(separator)
|
593
|
+
number.negative? ? "-#{combined_result}" : combined_result
|
594
|
+
end
|
595
|
+
|
596
|
+
# Look up separator from locale file, default to space
|
597
|
+
def lookup_decimal_separator(locale)
|
598
|
+
I18n.t("#{I18N_DECIMAL_UNITS_KEY}.separator", locale: locale, default: SPACE_SEPARATOR)
|
599
|
+
end
|
600
|
+
end
|
601
|
+
end
|
602
|
+
|
603
|
+
# Indian subcontinent number formatting system using traditional South Asian units.
|
604
|
+
#
|
605
|
+
# This system implements the traditional Indian number formatting convention
|
606
|
+
# using the progression: thousand (1,000), lakh (100,000), crore (10,000,000).
|
607
|
+
# This aligns with how large numbers are conceptualized and spoken in Hindi,
|
608
|
+
# Urdu, Bengali, and Indian English contexts.
|
609
|
+
#
|
610
|
+
# The system follows a mixed base progression: thousands up to lakh,
|
611
|
+
# then lakhs up to crore, maintaining the linguistic patterns familiar
|
612
|
+
# to speakers of Indian subcontinent languages.
|
613
|
+
#
|
614
|
+
# @example Formatting examples
|
615
|
+
# # Indian English
|
616
|
+
# HumanNumber.human_number(123_456, locale: :'en-IN') #=> "1 lakh 23K 456"
|
617
|
+
# HumanNumber.human_number(12_345_678, locale: :'en-IN') #=> "1 crore 23 lakh 45K 678"
|
618
|
+
#
|
619
|
+
# # Hindi/Urdu/Bengali contexts
|
620
|
+
# HumanNumber.human_number(100_000, locale: :hi) #=> "1 lakh"
|
621
|
+
# HumanNumber.human_number(10_000_000, locale: :ur) #=> "1 crore"
|
622
|
+
#
|
623
|
+
# # Abbreviated mode
|
624
|
+
# HumanNumber.human_number(12_345_678, locale: :'en-IN', max_digits: 2) #=> "1.2 crore"
|
625
|
+
#
|
626
|
+
# @example Supported locales and currencies
|
627
|
+
# # Locales: [:hi, :ur, :bn, :'en-IN']
|
628
|
+
# # Currencies: ['INR']
|
629
|
+
#
|
630
|
+
# @example Unit progression
|
631
|
+
# # 1,000 = 1 thousand
|
632
|
+
# # 100,000 = 1 lakh (not "100 thousand")
|
633
|
+
# # 10,000,000 = 1 crore (not "10 million")
|
634
|
+
#
|
635
|
+
# @note Unlike Western and East Asian systems, the Indian system
|
636
|
+
# uses a mixed base progression that doesn't follow strict powers
|
637
|
+
# of 10 or 1000, reflecting the unique linguistic structure.
|
638
|
+
#
|
639
|
+
# @see UNIT_DEFINITIONS
|
640
|
+
# @since 0.1.10
|
641
|
+
class IndianSystem < DefaultSystem
|
642
|
+
UNIT_DEFINITIONS = [
|
643
|
+
{ key: :crore, divisor: 10_000_000 },
|
644
|
+
{ key: :lakh, divisor: 100_000 },
|
645
|
+
{ key: :thousand, divisor: 1_000 },
|
646
|
+
].freeze
|
647
|
+
|
648
|
+
class << self
|
649
|
+
private
|
650
|
+
|
651
|
+
def unit_definitions
|
652
|
+
UNIT_DEFINITIONS
|
653
|
+
end
|
654
|
+
end
|
655
|
+
end
|
656
|
+
end
|
657
|
+
end
|
data/lib/human_number/version.rb
CHANGED