human_number 0.1.9 → 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 +39 -92
- data/README.md +97 -4
- data/lib/human_number/formatters/number.rb +11 -398
- 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
@@ -1,25 +1,13 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require_relative "../locale_support"
|
4
|
+
require_relative "../number_system"
|
4
5
|
|
5
6
|
module HumanNumber
|
6
7
|
module Formatters
|
7
8
|
class Number
|
8
9
|
extend ActionView::Helpers::NumberHelper
|
9
10
|
|
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
11
|
class << self
|
24
12
|
# Internal formatter for numbers with intelligent abbreviations.
|
25
13
|
# All parameters are required - use HumanNumber.human_number for user-friendly interface.
|
@@ -67,14 +55,14 @@ module HumanNumber
|
|
67
55
|
currency_options[:locale] = precision_locale
|
68
56
|
currency_options[:format] = "%n" # Always format as just the number (no currency symbol)
|
69
57
|
|
70
|
-
number_to_currency(number, **currency_options) || ZERO_STRING
|
58
|
+
number_to_currency(number, **currency_options) || NumberSystem::ZERO_STRING
|
71
59
|
end
|
72
60
|
|
73
61
|
# Default options for human number formatting
|
74
62
|
def default_options
|
75
63
|
{
|
76
64
|
abbr_units: true,
|
77
|
-
max_digits:
|
65
|
+
max_digits: nil,
|
78
66
|
min_unit: nil,
|
79
67
|
trim_zeros: true,
|
80
68
|
}.freeze
|
@@ -82,396 +70,21 @@ module HumanNumber
|
|
82
70
|
|
83
71
|
# Format numbers below min_unit threshold with locale-appropriate delimiters
|
84
72
|
def format_below_min_unit(number, locale)
|
85
|
-
|
73
|
+
NumberSystem::DefaultSystem.format_below_min_unit(number, locale)
|
86
74
|
end
|
87
75
|
|
88
76
|
private
|
89
77
|
|
90
78
|
def determine_system_class(locale)
|
91
|
-
|
79
|
+
system_type = NumberSystem.number_system(locale: locale)
|
92
80
|
|
93
|
-
case
|
94
|
-
when
|
95
|
-
EastAsianSystem
|
96
|
-
when
|
97
|
-
IndianSystem
|
81
|
+
case system_type
|
82
|
+
when :east_asian
|
83
|
+
NumberSystem::EastAsianSystem
|
84
|
+
when :indian
|
85
|
+
NumberSystem::IndianSystem
|
98
86
|
else
|
99
|
-
DefaultSystem
|
100
|
-
end
|
101
|
-
end
|
102
|
-
end
|
103
|
-
|
104
|
-
# Default number system (thousand, million, billion, trillion)
|
105
|
-
class DefaultSystem
|
106
|
-
TARGET_LOCALES = %i[en es fr de it pt ru nl sv da no].freeze
|
107
|
-
|
108
|
-
UNIT_DEFINITIONS = [
|
109
|
-
{ key: :trillion, divisor: 1_000_000_000_000 },
|
110
|
-
{ key: :billion, divisor: 1_000_000_000 },
|
111
|
-
{ key: :million, divisor: 1_000_000 },
|
112
|
-
{ key: :thousand, divisor: 1_000 },
|
113
|
-
].freeze
|
114
|
-
|
115
|
-
class << self
|
116
|
-
def format_number(number, locale:, abbr_units: true, min_unit: nil, max_digits: 2, trim_zeros: true)
|
117
|
-
return ZERO_STRING if number.zero?
|
118
|
-
|
119
|
-
# Check if number meets minimum unit threshold for human formatting
|
120
|
-
return Number.format_below_min_unit(number, locale) if min_unit && number.abs < min_unit
|
121
|
-
|
122
|
-
parts = if max_digits.nil?
|
123
|
-
format_in_complete_mode(number, locale, abbr_units, min_unit)
|
124
|
-
else
|
125
|
-
format_in_abbreviated_mode(number, locale, abbr_units, max_digits, trim_zeros, min_unit)
|
126
|
-
end
|
127
|
-
|
128
|
-
return number.to_s if parts.nil?
|
129
|
-
|
130
|
-
finalize_result(number, parts)
|
131
|
-
end
|
132
|
-
|
133
|
-
def format_in_complete_mode(number, locale, abbr_units, _min_unit)
|
134
|
-
# Complete mode shows all units: "1M 234K 567"
|
135
|
-
unit_breakdown = break_down_into_all_units(number)
|
136
|
-
return nil if unit_breakdown.empty?
|
137
|
-
|
138
|
-
format_all_unit_parts(unit_breakdown, locale, abbr_units)
|
139
|
-
end
|
140
|
-
|
141
|
-
def format_in_abbreviated_mode(number, locale, abbr_units, max_digits, trim_zeros, _min_unit)
|
142
|
-
# Abbreviated mode shows single largest unit: "1.2M"
|
143
|
-
unit_breakdown = break_down_into_largest_unit(number)
|
144
|
-
return nil if unit_breakdown.empty?
|
145
|
-
|
146
|
-
format_abbreviated_unit_parts(unit_breakdown, 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
|
-
# Check if we're in human formatting mode (any non-:units parts exist)
|
204
|
-
human_formatting_applied = breakdown.any? { |unit_info| unit_info[:unit_key] != :units }
|
205
|
-
|
206
|
-
breakdown.map do |unit_info|
|
207
|
-
if unit_info[:unit_key] == :units
|
208
|
-
if human_formatting_applied
|
209
|
-
# Part of human formatting - no delimiters
|
210
|
-
unit_info[:count].to_s
|
211
|
-
else
|
212
|
-
# Numbers below minimum unit threshold - apply locale formatting
|
213
|
-
Number.format_below_min_unit(unit_info[:count], locale)
|
214
|
-
end
|
215
|
-
else
|
216
|
-
# Format with unit symbol (e.g., "1.2" + "M" = "1.2M")
|
217
|
-
format_number_with_unit_symbol(unit_info, locale, abbr_units, max_digits, trim_zeros)
|
218
|
-
end
|
219
|
-
end
|
220
|
-
end
|
221
|
-
|
222
|
-
# Format raw number without unit symbols
|
223
|
-
def format_raw_number_count(count, max_digits, trim_zeros)
|
224
|
-
format_unit_count(count, max_digits:, trim_zeros:, apply_rounding: false)
|
225
|
-
end
|
226
|
-
|
227
|
-
# Format number with unit symbol (e.g., "1.2M")
|
228
|
-
def format_number_with_unit_symbol(unit_info, locale, abbr_units, max_digits, trim_zeros)
|
229
|
-
symbol = lookup_unit_symbol(locale, unit_info[:unit_key], abbr_units)
|
230
|
-
format_with_symbol(unit_info[:count], symbol, abbr_units, max_digits:, trim_zeros:)
|
231
|
-
end
|
232
|
-
|
233
|
-
# Format unit with symbol for complete mode (e.g., "234K")
|
234
|
-
def format_complete_unit_with_symbol(unit_info, locale, abbr_units)
|
235
|
-
symbol = lookup_unit_symbol(locale, unit_info[:unit_key], abbr_units)
|
236
|
-
count_text = unit_info[:count].to_i.to_s
|
237
|
-
separator = determine_unit_value_separator(abbr_units)
|
238
|
-
formatted_symbol = apply_symbol_formatting_rules(symbol, abbr_units)
|
239
|
-
"#{count_text}#{separator}#{formatted_symbol}"
|
240
|
-
end
|
241
|
-
|
242
|
-
def format_unit_count(count, max_digits: 2, trim_zeros: true, apply_rounding: true)
|
243
|
-
return count.to_s unless count.is_a?(Numeric)
|
244
|
-
|
245
|
-
rounded = apply_rounding ? round_to_significant_digits(count, max_digits) : count
|
246
|
-
|
247
|
-
format_rounded_count(rounded, max_digits, trim_zeros, apply_rounding, count)
|
248
|
-
end
|
249
|
-
|
250
|
-
def format_rounded_count(rounded, max_digits, trim_zeros, apply_rounding, original_count)
|
251
|
-
if trim_zeros
|
252
|
-
format_with_trimmed_zeros(rounded)
|
253
|
-
elsif apply_rounding
|
254
|
-
format_with_fixed_decimals(rounded, max_digits)
|
255
|
-
else
|
256
|
-
original_count.to_s
|
257
|
-
end
|
258
|
-
end
|
259
|
-
|
260
|
-
def format_with_trimmed_zeros(rounded)
|
261
|
-
if rounded == rounded.to_i
|
262
|
-
rounded.to_i.to_s
|
263
|
-
else
|
264
|
-
rounded.to_s
|
265
|
-
end
|
266
|
-
end
|
267
|
-
|
268
|
-
def format_with_fixed_decimals(rounded, max_digits)
|
269
|
-
integer_digits = rounded.to_i.to_s.length
|
270
|
-
decimal_places = [0, max_digits - integer_digits].max
|
271
|
-
format("%.#{decimal_places}f", rounded)
|
272
|
-
end
|
273
|
-
|
274
|
-
def round_to_significant_digits(number, digits)
|
275
|
-
return handle_edge_cases(number, digits) if should_handle_edge_case?(number, digits)
|
276
|
-
|
277
|
-
abs_num = number.abs
|
278
|
-
|
279
|
-
# Simple significant digits rounding using scientific notation
|
280
|
-
magnitude = Math.log10(abs_num).floor
|
281
|
-
scale_factor = 10**(digits - 1 - magnitude)
|
282
|
-
|
283
|
-
rounded = (abs_num * scale_factor).round / scale_factor.to_f
|
284
|
-
apply_sign(number, rounded)
|
285
|
-
end
|
286
|
-
|
287
|
-
def should_handle_edge_case?(number, digits)
|
288
|
-
number.zero? || digits <= 0
|
289
|
-
end
|
290
|
-
|
291
|
-
def handle_edge_cases(number, _digits)
|
292
|
-
return 0.0 if number.zero?
|
293
|
-
|
294
|
-
number
|
295
|
-
end
|
296
|
-
|
297
|
-
def apply_sign(original_number, result)
|
298
|
-
original_number.negative? ? -result : result
|
299
|
-
end
|
300
|
-
|
301
|
-
def count_integer_digits(int_part)
|
302
|
-
int_part.zero? ? SINGLE_DIGIT_LIMIT : int_part.to_s.length
|
303
|
-
end
|
304
|
-
|
305
|
-
# Look up the localized symbol for a unit (e.g., 'M' for million)
|
306
|
-
def lookup_unit_symbol(locale, unit_key, abbr_units)
|
307
|
-
section = abbr_units ? I18N_ABBR_UNITS_SECTION : I18N_UNITS_SECTION
|
308
|
-
|
309
|
-
I18n.t("#{I18N_DECIMAL_UNITS_KEY}.#{section}.#{unit_key}", locale:, default: nil) ||
|
310
|
-
I18n.t("#{I18N_DECIMAL_UNITS_KEY}.#{section}.#{unit_key}", locale: :en, default: nil) ||
|
311
|
-
unit_key.to_s.upcase
|
312
|
-
end
|
313
|
-
|
314
|
-
def format_with_symbol(value, symbol, abbr_units, max_digits: 2, trim_zeros: true)
|
315
|
-
# Apply significant digits rounding to unit values
|
316
|
-
formatted_count = format_unit_count(value, max_digits:, trim_zeros:, apply_rounding: true)
|
317
|
-
separator = determine_unit_value_separator(abbr_units)
|
318
|
-
formatted_symbol = apply_symbol_formatting_rules(symbol, abbr_units)
|
319
|
-
"#{formatted_count}#{separator}#{formatted_symbol}"
|
320
|
-
end
|
321
|
-
|
322
|
-
# Determine separator between number and unit ("1M" vs "1 million")
|
323
|
-
def determine_unit_value_separator(abbr_units)
|
324
|
-
abbr_units ? EMPTY_SEPARATOR : SPACE_SEPARATOR
|
325
|
-
end
|
326
|
-
|
327
|
-
# Apply locale-specific formatting rules to unit symbols
|
328
|
-
def apply_symbol_formatting_rules(symbol, abbr_units)
|
329
|
-
return symbol if abbr_units || symbol.length <= 1
|
330
|
-
|
331
|
-
symbol.downcase # Western lowercase rule for full words
|
332
|
-
end
|
333
|
-
|
334
|
-
# Break down number into all applicable units for complete display
|
335
|
-
# Example: 1,234,567 -> [{million: 1}, {thousand: 234}, {units: 567}]
|
336
|
-
def break_down_into_all_units(number)
|
337
|
-
unit_breakdown = UnitBreakdown.new(number.abs, unit_definitions)
|
338
|
-
unit_breakdown.extract_all_units
|
339
|
-
end
|
340
|
-
|
341
|
-
# Helper class to manage unit breakdown state
|
342
|
-
class UnitBreakdown
|
343
|
-
def initialize(amount, unit_definitions)
|
344
|
-
@remaining_amount = amount
|
345
|
-
@unit_definitions = unit_definitions
|
346
|
-
@unit_parts = []
|
347
|
-
end
|
348
|
-
|
349
|
-
def extract_all_units
|
350
|
-
extract_major_units
|
351
|
-
add_remaining_as_units
|
352
|
-
@unit_parts
|
353
|
-
end
|
354
|
-
|
355
|
-
private
|
356
|
-
|
357
|
-
def extract_major_units
|
358
|
-
@unit_definitions.each do |unit|
|
359
|
-
extract_single_unit(unit) if @remaining_amount >= unit[:divisor]
|
360
|
-
end
|
361
|
-
end
|
362
|
-
|
363
|
-
def extract_single_unit(unit)
|
364
|
-
unit_count = (@remaining_amount / unit[:divisor]).to_i
|
365
|
-
return if unit_count.zero?
|
366
|
-
|
367
|
-
@unit_parts << create_unit_part(unit[:key], unit_count, unit[:divisor])
|
368
|
-
@remaining_amount %= unit[:divisor]
|
369
|
-
end
|
370
|
-
|
371
|
-
def add_remaining_as_units
|
372
|
-
return unless @remaining_amount.positive?
|
373
|
-
|
374
|
-
@unit_parts << create_unit_part(:units, @remaining_amount, 1)
|
375
|
-
end
|
376
|
-
|
377
|
-
def create_unit_part(unit_key, count, divisor)
|
378
|
-
{ unit_key: unit_key, count: count, divisor: divisor }
|
379
|
-
end
|
380
|
-
end
|
381
|
-
|
382
|
-
# Format unit parts for complete display (e.g., "1M 234K 567")
|
383
|
-
def format_all_unit_parts(breakdown, locale, abbr_units)
|
384
|
-
# Check if we're in human formatting mode (any non-:units parts exist)
|
385
|
-
human_formatting_applied = breakdown.any? { |unit_info| unit_info[:unit_key] != :units }
|
386
|
-
|
387
|
-
breakdown.map do |unit_info|
|
388
|
-
if unit_info[:unit_key] == :units
|
389
|
-
if human_formatting_applied
|
390
|
-
# Part of human formatting - no delimiters
|
391
|
-
unit_info[:count].to_i.to_s
|
392
|
-
else
|
393
|
-
# Numbers below minimum unit threshold - apply locale formatting
|
394
|
-
Number.format_below_min_unit(unit_info[:count].to_i, locale)
|
395
|
-
end
|
396
|
-
else
|
397
|
-
# Format each unit part with its symbol
|
398
|
-
format_complete_unit_with_symbol(unit_info, locale, abbr_units)
|
399
|
-
end
|
400
|
-
end
|
401
|
-
end
|
402
|
-
|
403
|
-
# Apply sign and join all parts into final result
|
404
|
-
def finalize_result(number, formatted_parts)
|
405
|
-
combined_result = formatted_parts.join(SPACE_SEPARATOR)
|
406
|
-
number.negative? ? "-#{combined_result}" : combined_result
|
407
|
-
end
|
408
|
-
end
|
409
|
-
end
|
410
|
-
|
411
|
-
# East Asian number system (thousand, ten_thousand, hundred_million, trillion)
|
412
|
-
class EastAsianSystem < DefaultSystem
|
413
|
-
TARGET_LOCALES = %i[ko ja zh zh-CN zh-TW].freeze
|
414
|
-
|
415
|
-
UNIT_DEFINITIONS = [
|
416
|
-
{ key: :trillion, divisor: 1_000_000_000_000 },
|
417
|
-
{ key: :hundred_million, divisor: 100_000_000 },
|
418
|
-
{ key: :ten_thousand, divisor: 10_000 },
|
419
|
-
].freeze
|
420
|
-
|
421
|
-
class << self
|
422
|
-
def format_number(number, locale:, abbr_units: true, min_unit: nil, max_digits: 2, trim_zeros: true)
|
423
|
-
@current_locale = locale.to_sym
|
424
|
-
super
|
425
|
-
end
|
426
|
-
|
427
|
-
private
|
428
|
-
|
429
|
-
def unit_definitions
|
430
|
-
UNIT_DEFINITIONS
|
431
|
-
end
|
432
|
-
|
433
|
-
def format_with_symbol(value, symbol, _abbr_units, max_digits: 2, trim_zeros: true)
|
434
|
-
# East Asian: no separator regardless of abbr_units
|
435
|
-
# Apply significant digits rounding to unit values
|
436
|
-
formatted_count = format_unit_count(value, max_digits:, trim_zeros:, apply_rounding: true)
|
437
|
-
formatted_symbol = apply_symbol_formatting_rules(symbol, true)
|
438
|
-
"#{formatted_count}#{formatted_symbol}"
|
439
|
-
end
|
440
|
-
|
441
|
-
# Keep original case for Asian characters (만, 억, 조)
|
442
|
-
def apply_symbol_formatting_rules(symbol, _abbr_units)
|
443
|
-
symbol
|
444
|
-
end
|
445
|
-
|
446
|
-
# Apply culturally appropriate separators for East Asian languages
|
447
|
-
def finalize_result(number, formatted_parts)
|
448
|
-
separator = lookup_decimal_separator(@current_locale)
|
449
|
-
combined_result = formatted_parts.join(separator)
|
450
|
-
number.negative? ? "-#{combined_result}" : combined_result
|
451
|
-
end
|
452
|
-
|
453
|
-
# Look up separator from locale file, default to space
|
454
|
-
def lookup_decimal_separator(locale)
|
455
|
-
I18n.t("#{I18N_DECIMAL_UNITS_KEY}.separator", locale: locale, default: SPACE_SEPARATOR)
|
456
|
-
end
|
457
|
-
end
|
458
|
-
end
|
459
|
-
|
460
|
-
# Indian subcontinent number system (thousand, lakh, crore)
|
461
|
-
class IndianSystem < DefaultSystem
|
462
|
-
TARGET_LOCALES = %i[hi ur bn en-IN].freeze
|
463
|
-
|
464
|
-
UNIT_DEFINITIONS = [
|
465
|
-
{ key: :crore, divisor: 10_000_000 },
|
466
|
-
{ key: :lakh, divisor: 100_000 },
|
467
|
-
{ key: :thousand, divisor: 1_000 },
|
468
|
-
].freeze
|
469
|
-
|
470
|
-
class << self
|
471
|
-
private
|
472
|
-
|
473
|
-
def unit_definitions
|
474
|
-
UNIT_DEFINITIONS
|
87
|
+
NumberSystem::DefaultSystem
|
475
88
|
end
|
476
89
|
end
|
477
90
|
end
|