human_number 0.1.10 → 0.2.1

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.
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module HumanNumber
4
- VERSION = "0.1.10"
4
+ VERSION = "0.2.1"
5
5
  end