plurimath 0.10.7 → 0.11.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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/release.yml +3 -2
  3. data/README.adoc +343 -44
  4. data/lib/plurimath/asciimath/parse.rb +6 -2
  5. data/lib/plurimath/configuration.rb +17 -0
  6. data/lib/plurimath/deprecation.rb +81 -0
  7. data/lib/plurimath/errors/configuration_error.rb +27 -0
  8. data/lib/plurimath/errors/deprecation_error.rb +33 -0
  9. data/lib/plurimath/errors/error.rb +6 -0
  10. data/lib/plurimath/errors/formatter/unsupported_base.rb +1 -1
  11. data/lib/plurimath/errors/formatter/unsupported_locale.rb +18 -0
  12. data/lib/plurimath/errors/omml/unsupported_node_error.rb +1 -1
  13. data/lib/plurimath/errors/parse_error.rb +1 -1
  14. data/lib/plurimath/errors/parse_option_error.rb +34 -0
  15. data/lib/plurimath/formatter/numbers/base.rb +18 -8
  16. data/lib/plurimath/formatter/numbers/base_notation.rb +67 -0
  17. data/lib/plurimath/formatter/numbers/digit_sequence.rb +96 -0
  18. data/lib/plurimath/formatter/numbers/format_options.rb +141 -0
  19. data/lib/plurimath/formatter/numbers/fraction.rb +50 -93
  20. data/lib/plurimath/formatter/numbers/integer.rb +30 -6
  21. data/lib/plurimath/formatter/numbers/notation_renderer.rb +128 -0
  22. data/lib/plurimath/formatter/numbers/number_renderer.rb +66 -0
  23. data/lib/plurimath/formatter/numbers/parts.rb +69 -0
  24. data/lib/plurimath/formatter/numbers/parts_renderer.rb +30 -0
  25. data/lib/plurimath/formatter/numbers/precision_resolver.rb +54 -0
  26. data/lib/plurimath/formatter/numbers/sign_renderer.rb +28 -0
  27. data/lib/plurimath/formatter/numbers/significant.rb +77 -103
  28. data/lib/plurimath/formatter/numbers/source.rb +120 -0
  29. data/lib/plurimath/formatter/numbers/symbol_resolver.rb +55 -0
  30. data/lib/plurimath/formatter/numbers.rb +11 -0
  31. data/lib/plurimath/formatter/standard.rb +32 -42
  32. data/lib/plurimath/formatter/supported_locales.rb +27 -0
  33. data/lib/plurimath/formatter.rb +1 -2
  34. data/lib/plurimath/html/constants.rb +2 -0
  35. data/lib/plurimath/html/parse.rb +77 -14
  36. data/lib/plurimath/html/parser.rb +15 -3
  37. data/lib/plurimath/html/transform.rb +193 -91
  38. data/lib/plurimath/html/transform_utility.rb +61 -0
  39. data/lib/plurimath/html.rb +1 -0
  40. data/lib/plurimath/latex/parse.rb +7 -1
  41. data/lib/plurimath/latex/transform.rb +5 -5
  42. data/lib/plurimath/math/function/lim.rb +6 -0
  43. data/lib/plurimath/math/number.rb +8 -2
  44. data/lib/plurimath/math/symbols/cdot.rb +1 -1
  45. data/lib/plurimath/math/symbols/exclam.rb +1 -1
  46. data/lib/plurimath/math/symbols/minus.rb +1 -1
  47. data/lib/plurimath/math/symbols/percent.rb +1 -1
  48. data/lib/plurimath/math/symbols/pi.rb +1 -1
  49. data/lib/plurimath/math/symbols/slash.rb +1 -1
  50. data/lib/plurimath/math.rb +56 -8
  51. data/lib/plurimath/number_formatter.rb +57 -27
  52. data/lib/plurimath/unicode_math/parse.rb +7 -1
  53. data/lib/plurimath/unicode_math/transform.rb +2 -2
  54. data/lib/plurimath/version.rb +1 -1
  55. data/lib/plurimath.rb +23 -1
  56. metadata +21 -4
  57. data/lib/plurimath/formatter/number_formatter.rb +0 -115
  58. data/lib/plurimath/formatter/numeric_formatter.rb +0 -187
@@ -3,37 +3,42 @@
3
3
  module Plurimath
4
4
  module Formatter
5
5
  module Numbers
6
+ # Transforms fraction digits on Parts before localized rendering.
6
7
  class Fraction < Base
7
8
  attr_reader :decimal, :precision, :separator, :group
8
9
 
9
- DEFAULT_PRECISION = 3
10
- DEFAULT_STRINGS = { empty: "", zero: "0", dot: ".", f: "F" }.freeze
10
+ DEFAULT_PRECISION = FormatOptions::DEFAULT_FRACTION_PRECISION
11
+ DEFAULT_STRINGS = { empty: "", zero: "0", dot: "." }.freeze
11
12
 
12
- def initialize(symbols = {})
13
+ def initialize(options)
13
14
  super
14
- @group = symbols[:fraction_group_digits]
15
- @decimal = symbols.fetch(:decimal, DEFAULT_STRINGS[:dot])
16
- @int_group = symbols[:group]
17
- @separator = symbols[:fraction_group].to_s
18
- @precision = symbols.fetch(:precision, DEFAULT_PRECISION)
19
- @digit_count = symbols[:digit_count].to_i
15
+ @group = self.options.fraction_group_digits
16
+ @decimal = self.options.decimal
17
+ @separator = self.options.fraction_group
18
+ @precision = self.options.precision || DEFAULT_PRECISION
19
+ @digit_count = self.options.digit_count
20
20
  end
21
21
 
22
- def apply(fraction, result, integer_formatter)
23
- precision = symbols[:precision] || @precision
24
- @result = result
25
- @integer_formatter = integer_formatter
26
- return DEFAULT_STRINGS[:empty] unless precision.positive?
22
+ # Keep fraction preparation on structured parts; localized rendering and
23
+ # grouping happen later at the PartsRenderer boundary.
24
+ def apply_parts(parts, precision: self.precision)
25
+ precision = precision.to_i
26
+ return parts.with_digits(fraction_digits: DEFAULT_STRINGS[:empty]) unless precision.positive?
27
27
 
28
- fraction = change_base(fraction) if !base_default? && fraction.match?(/[1-9]/)
28
+ @integer_digits = parts.integer_digits
29
29
 
30
+ fraction = parts.fraction_digits
31
+ fraction = change_base(fraction, precision) if !base_default? && fraction.match?(/[1-9]/)
30
32
  number = if @digit_count.positive?
31
- digit_count_format(fraction)
33
+ digit_count_format(fraction, precision)
32
34
  else
33
35
  format(fraction, precision)
34
36
  end
35
- formatted_number = format_groups(number) if number && !number.empty?
36
- formatted_number ? decimal + formatted_number : DEFAULT_STRINGS[:empty]
37
+
38
+ parts.with_digits(
39
+ integer_digits: integer_digits,
40
+ fraction_digits: number.to_s,
41
+ )
37
42
  end
38
43
 
39
44
  def format(number, precision)
@@ -43,6 +48,7 @@ module Plurimath
43
48
  end
44
49
 
45
50
  def format_groups(string, length = group)
51
+ string = capitalize_hex_digits(string)
46
52
  length = string.length if group.to_i.zero?
47
53
 
48
54
  change_format(string, length)
@@ -59,32 +65,30 @@ module Plurimath
59
65
  tokens.compact.join(separator)
60
66
  end
61
67
 
62
- def digit_count_format(fraction)
63
- integer = raw_integer + DEFAULT_STRINGS[:dot] + fraction
68
+ # The digit_count option is a total visible-digit budget, so fraction
69
+ # rounding can carry back into the integer digits.
70
+ def digit_count_format(fraction, precision)
71
+ integer = integer_digits + DEFAULT_STRINGS[:dot] + fraction
64
72
  int_length = integer.length.pred # integer length; excluding the decimal point
65
73
  if int_length > @digit_count
66
- # When digit_count is less than or equal to the integer length,
67
- # omit the fractional part entirely and handle rounding in the integer
68
- if @digit_count <= raw_integer.length
69
- # Check if we need to round the integer up based on the fractional part
70
- if fraction[0] && DIGIT_VALUE[fraction[0]] >= threshold
71
- # Round up the integer part
72
- round_integer([], 1)
73
- end
74
+ # When digit_count is within the integer length, omit the fraction
75
+ # and let the integer carry handle rounding.
76
+ if @digit_count <= integer_digits.length
77
+ round_integer([], 1) if digit_sequence.round_up?(fraction[0])
74
78
  return DEFAULT_STRINGS[:empty]
75
79
  end
76
80
  round_base_string(fraction)
77
81
  elsif int_length < @digit_count
78
- fraction + (DEFAULT_STRINGS[:zero] * (update_digit_count(fraction) - int_length))
82
+ fraction + (DEFAULT_STRINGS[:zero] * (update_digit_count(fraction, precision) - int_length))
79
83
  else
80
84
  fraction
81
85
  end
82
86
  end
83
87
 
84
- def update_digit_count(number)
85
- return @digit_count unless zeros_count_in(number) == @precision
88
+ def update_digit_count(number, precision)
89
+ return @digit_count unless zeros_count_in(number) == precision
86
90
 
87
- @digit_count - @precision + 1
91
+ @digit_count - precision + 1
88
92
  end
89
93
 
90
94
  def zeros_count_in(number)
@@ -94,85 +98,38 @@ module Plurimath
94
98
  end
95
99
 
96
100
  def round_base_string(fraction)
97
- # Extract the digits we need, plus one extra digit for rounding decision
98
101
  digits = fraction[0..frac_digit_count].chars
99
102
  discard_char = digits.pop
100
103
  return DEFAULT_STRINGS[:empty] unless discard_char
101
- # If the discarded digit is below the rounding threshold (< base/2), truncate
102
- return digits.join if DIGIT_VALUE[discard_char] < threshold
103
-
104
- # Perform carry propagation for rounding up
105
- carry = 1
106
- rounded_reversed = []
107
- digits.reverse_each do |digit|
108
- next rounded_reversed << digit unless carry.positive?
109
-
110
- # If current digit is at max value for this base (e.g., 'f' for base 16),
111
- # set it to '0' and continue carrying
112
- rounded_reversed << if DIGIT_VALUE[digit] == base.pred
113
- DEFAULT_STRINGS[:zero]
114
- else
115
- # Otherwise, increment this digit and stop carrying
116
- carry = 0
117
- next_mapping_char(digit)
118
- end
119
- end
120
104
 
121
- # If we still have a carry after processing all fractional digits,
122
- # we need to round up the integer part
105
+ return digits.join unless digit_sequence.round_up?(discard_char)
106
+
107
+ rounded_reversed, carry = digit_sequence.increment_reversed(digits.reverse)
123
108
  round_integer(rounded_reversed, carry) if carry.positive?
124
109
  rounded_reversed.reverse.join unless rounded_reversed.empty?
125
110
  end
126
111
 
127
112
  def round_integer(fraction_digits_reversed, carry = 1)
128
- # Propagate carry into the integer part, updating the formatted result
129
- incremented, carry = increment_integer_digits(
130
- raw_integer.chars.reverse, carry
113
+ incremented, carry = digit_sequence.increment_reversed(
114
+ integer_digits.chars.reverse,
115
+ carry: carry,
131
116
  )
117
+ incremented = incremented.reverse.join
132
118
  new_integer = [incremented]
133
119
  if carry.positive?
134
- # If carry propagates through all integer digits (e.g., 9+1=10 in base 10),
135
- # we need to prepend '1' and remove one fractional digit to maintain digit_count
136
120
  fraction_digits_reversed.pop
137
121
  new_integer.insert(0, "1")
138
122
  end
139
- # Update the result array with the new integer value
140
- @result[0] = @integer_formatter.format_groups(new_integer.join)
141
- end
142
-
143
- def increment_integer_digits(digits, carry)
144
- # Skip over separator characters while propagating carry through integer digits
145
- str_chars = [decimal, @int_group]
146
- digits.each_with_index do |digit, index|
147
- next if str_chars.include?(digit)
148
-
149
- if DIGIT_VALUE[digit] == base.pred
150
- # Digit is at max value (e.g., 'f' for base 16), set to '0' and continue carry
151
- digits[index] = DEFAULT_STRINGS[:zero]
152
- else
153
- # Increment this digit and stop carrying
154
- digits[index] = next_mapping_char(digit)
155
- break carry = 0
156
- end
157
- end
158
-
159
- [digits.reverse.join, carry]
123
+ @integer_digits = new_integer.join
160
124
  end
161
125
 
162
- def change_base(number)
163
- # Convert fractional part from base 10 to target base using rational arithmetic
164
- # to avoid floating-point rounding errors.
165
- # Algorithm: repeatedly multiply fraction by base, extract integer part as next digit
166
- # Represent the fractional part exactly as a rational number to avoid
167
- # binary floating-point rounding errors when converting bases.
168
- # Note: The input `number` is always in decimal (base 10) format,
169
- # so we use 10 as the denominator base regardless of the target base.
126
+ def change_base(number, precision = self.precision)
127
+ # Keep the decimal fraction exact while converting to the target base.
170
128
  fraction = Rational(number.to_i, 10**number.length)
171
129
 
172
130
  base_result = []
173
- digits = @precision || number.length
174
131
 
175
- digits.times do
132
+ precision.times do
176
133
  fraction *= base
177
134
  digit = fraction.to_i
178
135
  alpha_digit = HEX_ALPHANUMERIC[digit]
@@ -183,12 +140,12 @@ module Plurimath
183
140
  base_result.join
184
141
  end
185
142
 
186
- def raw_integer
187
- @result[0].delete(@int_group.to_s)
143
+ def integer_digits
144
+ @integer_digits
188
145
  end
189
146
 
190
147
  def frac_digit_count
191
- @digit_count - raw_integer.length
148
+ @digit_count - integer_digits.length
192
149
  end
193
150
  end
194
151
  end
@@ -3,15 +3,18 @@
3
3
  module Plurimath
4
4
  module Formatter
5
5
  module Numbers
6
+ # Converts integer digits to the target base and applies integer grouping.
6
7
  class Integer < Base
7
- attr_reader :separator, :groups
8
+ attr_reader :separator, :groups, :padding, :padding_digits,
9
+ :padding_group_digits
8
10
 
9
- DEFAULT_SEPARATOR = ","
10
-
11
- def initialize(symbols = {})
11
+ def initialize(options)
12
12
  super
13
- @groups = symbols[:group_digits] || 3
14
- @separator = symbols[:group] || DEFAULT_SEPARATOR
13
+ @groups = self.options.group_digits
14
+ @separator = self.options.group
15
+ @padding = self.options.padding
16
+ @padding_digits = self.options.padding_digits
17
+ @padding_group_digits = self.options.padding_group_digits
15
18
  end
16
19
 
17
20
  def apply(number)
@@ -19,6 +22,8 @@ module Plurimath
19
22
  end
20
23
 
21
24
  def format_groups(string)
25
+ string = capitalize_hex_digits(string)
26
+ string = pad_integer(string)
22
27
  tokens = []
23
28
 
24
29
  until string.empty?
@@ -38,6 +43,25 @@ module Plurimath
38
43
 
39
44
  number.to_i.to_s(base)
40
45
  end
46
+
47
+ private
48
+
49
+ def pad_integer(string)
50
+ target_width = padding_target_width(string)
51
+ return string unless target_width > string.length
52
+
53
+ string.rjust(target_width, padding)
54
+ end
55
+
56
+ def padding_target_width(string)
57
+ return padding_digits if padding_digits.positive?
58
+ return string.length unless padding_group_digits.positive?
59
+
60
+ remainder = string.length % padding_group_digits
61
+ return string.length if remainder.zero?
62
+
63
+ string.length + padding_group_digits - remainder
64
+ end
41
65
  end
42
66
  end
43
67
  end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plurimath
4
+ module Formatter
5
+ module Numbers
6
+ # Builds e/scientific/engineering notation while delegating coefficient
7
+ # localization back through the structured renderer.
8
+ class NotationRenderer
9
+ SUPPORTED_NOTATIONS = %i[e scientific engineering].freeze
10
+
11
+ def initialize(options)
12
+ @options = options
13
+ @precision = (@options.precision || 0).to_i
14
+ @exponent_sign_renderer = SignRenderer.new(@options.exponent_sign)
15
+ end
16
+
17
+ def render(source, notation)
18
+ case notation.to_sym
19
+ when :e
20
+ render_e(source)
21
+ when :scientific
22
+ render_scientific(source)
23
+ when :engineering
24
+ render_engineering(source)
25
+ end
26
+ end
27
+
28
+ def self.supported?(notation)
29
+ SUPPORTED_NOTATIONS.include?(notation&.to_sym)
30
+ end
31
+
32
+ private
33
+
34
+ attr_reader :exponent_sign_renderer, :options, :precision
35
+
36
+ def render_e(source)
37
+ localized_notation_parts(source).join(options.exponent_separator.to_s)
38
+ end
39
+
40
+ def render_scientific(source)
41
+ localized_notation_parts(source).join(" #{options.times} 10^")
42
+ end
43
+
44
+ def render_engineering(source)
45
+ parts = notation_parts(source)
46
+ parts = engineering_notation_parts(parts) unless source.decimal.zero?
47
+ parts[0] = localize_parts(source, parts[0], precision: engineering_precision(source))
48
+ parts.join(" #{options.times} 10^")
49
+ end
50
+
51
+ def localize_parts(source, parts, precision:)
52
+ NumberRenderer.new(
53
+ source,
54
+ options,
55
+ ).format_parts(
56
+ parts,
57
+ precision: precision,
58
+ )
59
+ end
60
+
61
+ def localized_notation_parts(source)
62
+ parts = notation_parts(source)
63
+ parts[0] = localize_parts(source, parts[0], precision: precision)
64
+ parts[1] = render_exponent(parts[1])
65
+ parts
66
+ end
67
+
68
+ def notation_parts(source)
69
+ return [source.to_parts(base: options.base), 0] if source.decimal.zero?
70
+
71
+ parts = source.to_parts(base: Base::DEFAULT_BASE)
72
+ digits, exponent = significant_digits_and_exponent(parts)
73
+ [
74
+ Parts.new(
75
+ sign: parts.sign,
76
+ base: options.base,
77
+ integer_digits: digits[0],
78
+ fraction_digits: digits[1..].to_s,
79
+ ),
80
+ exponent,
81
+ ]
82
+ end
83
+
84
+ def engineering_notation_parts(parts)
85
+ coefficient, exponent = parts
86
+ index = exponent % 3
87
+ exponent -= index
88
+ digits = "#{coefficient.integer_digits}#{coefficient.fraction_digits}"
89
+ integer_length = index + 1
90
+ integer_digits = digits[0...integer_length].to_s.ljust(integer_length, "0")
91
+
92
+ [
93
+ Parts.new(
94
+ sign: coefficient.sign,
95
+ base: options.base,
96
+ integer_digits: integer_digits,
97
+ fraction_digits: digits[integer_length..].to_s,
98
+ ),
99
+ render_exponent(exponent),
100
+ ]
101
+ end
102
+
103
+ def engineering_precision(source)
104
+ return precision if precision.positive?
105
+
106
+ [source.significant_digit_count - 1, 0].max
107
+ end
108
+
109
+ def significant_digits_and_exponent(parts)
110
+ if parts.integer_zero?
111
+ index = parts.fraction_digits.index(/[1-9]/)
112
+ [parts.fraction_digits[index..], -(index + 1)]
113
+ else
114
+ digits = "#{parts.integer_digits}#{parts.fraction_digits}"
115
+ index = digits.index(/[1-9]/)
116
+ [digits[index..], parts.integer_digits.length - index - 1]
117
+ end
118
+ end
119
+
120
+ def render_exponent(exponent)
121
+ return "0" if exponent.zero?
122
+
123
+ exponent_sign_renderer.apply(exponent, exponent.abs.to_s)
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plurimath
4
+ module Formatter
5
+ module Numbers
6
+ # Orchestrates Source and Parts through numeric transforms and final
7
+ # renderers; low-level digit rules stay in composed helpers.
8
+ class NumberRenderer
9
+ attr_reader :options, :source
10
+
11
+ def initialize(source, options)
12
+ @source = source
13
+ @options = options
14
+ @base_notation = BaseNotation.new(@options)
15
+ @integer_format = Integer.new(@options)
16
+ @fraction_format = Fraction.new(@options)
17
+ @significant_format = Significant.new(@options)
18
+ @parts_renderer = PartsRenderer.new(
19
+ integer_formatter: @integer_format,
20
+ fraction_formatter: @fraction_format,
21
+ )
22
+ @sign_renderer = SignRenderer.new(@options.number_sign)
23
+ end
24
+
25
+ def format(precision: nil)
26
+ render_precision = precision || options.precision || source_precision
27
+ parts = source.to_parts(
28
+ base: options.base,
29
+ precision: render_precision,
30
+ )
31
+ format_parts(parts, precision: render_precision)
32
+ end
33
+
34
+ def format_parts(parts, precision:)
35
+ parts = parts.with_digits(
36
+ fraction_digits: parts.fraction_digits[0...precision.to_i].to_s,
37
+ )
38
+ parts = renderable_parts(parts, precision: precision)
39
+
40
+ parts = significant_format.apply_parts(parts) if significant_format.active?
41
+ result = parts_renderer.render(parts)
42
+
43
+ sign_renderer.apply(parts, base_notation.apply(result))
44
+ end
45
+
46
+ private
47
+
48
+ attr_reader :base_notation, :fraction_format, :integer_format,
49
+ :parts_renderer, :significant_format, :sign_renderer
50
+
51
+ def renderable_parts(parts, precision:)
52
+ parts = parts.with_digits(
53
+ integer_digits: integer_format.number_to_base(parts.integer_digits),
54
+ )
55
+ fraction_format.apply_parts(parts, precision: precision)
56
+ end
57
+
58
+ def source_precision
59
+ return 0 if source.decimal.fix == source.decimal
60
+
61
+ source.fraction_digits.length
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plurimath
4
+ module Formatter
5
+ module Numbers
6
+ # Normalized digit value object passed between transforms before
7
+ # localization.
8
+ class Parts
9
+ attr_reader :base, :fraction_digits, :integer_digits, :sign
10
+
11
+ def initialize(
12
+ sign:,
13
+ base:,
14
+ integer_digits:,
15
+ fraction_digits:
16
+ )
17
+ @sign = sign
18
+ @base = base
19
+ @integer_digits = normalize_integer(integer_digits)
20
+ @fraction_digits = fraction_digits.to_s
21
+ end
22
+
23
+ def fractional?
24
+ !fraction_digits.empty?
25
+ end
26
+
27
+ def integer_zero?
28
+ integer_digits == "0"
29
+ end
30
+
31
+ def negative?
32
+ sign.negative?
33
+ end
34
+
35
+ def significant_digit_count
36
+ digits.sub(/\A0+/, "").length
37
+ end
38
+
39
+ def with_digits(
40
+ integer_digits: self.integer_digits,
41
+ fraction_digits: self.fraction_digits
42
+ )
43
+ self.class.new(
44
+ sign: sign,
45
+ base: base,
46
+ integer_digits: integer_digits,
47
+ fraction_digits: fraction_digits,
48
+ )
49
+ end
50
+
51
+ def to_s
52
+ number = fractional? ? "#{integer_digits}.#{fraction_digits}" : integer_digits
53
+ negative? ? "-#{number}" : number
54
+ end
55
+
56
+ private
57
+
58
+ def digits
59
+ "#{integer_digits}#{fraction_digits}"
60
+ end
61
+
62
+ def normalize_integer(value)
63
+ normalized = value.to_s.sub(/\A0+(?=.)/, "")
64
+ normalized.empty? ? "0" : normalized
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plurimath
4
+ module Formatter
5
+ module Numbers
6
+ # Renders already-transformed Parts into localized integer/fraction text.
7
+ class PartsRenderer
8
+ def initialize(integer_formatter:, fraction_formatter:)
9
+ @integer_formatter = integer_formatter
10
+ @fraction_formatter = fraction_formatter
11
+ end
12
+
13
+ def render(parts)
14
+ rendered = integer_formatter.format_groups(parts.integer_digits)
15
+ return rendered unless parts.fractional?
16
+
17
+ "#{rendered}#{fraction_formatter.decimal}#{formatted_fraction(parts)}"
18
+ end
19
+
20
+ private
21
+
22
+ attr_reader :fraction_formatter, :integer_formatter
23
+
24
+ def formatted_fraction(parts)
25
+ fraction_formatter.format_groups(parts.fraction_digits)
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plurimath
4
+ module Formatter
5
+ module Numbers
6
+ # Chooses which precision source wins for one render call.
7
+ class PrecisionResolver
8
+ def resolve(source, precision:, base:, significant:, notation_supported:)
9
+ return precision if precision
10
+
11
+ significant_precision = significant_base_precision(source, base, significant)
12
+ return significant_precision if significant_precision
13
+
14
+ # Source owns input-derived digit lengths; this resolver only decides
15
+ # which precision rule wins. Plain decimal rendering preserves
16
+ # fractional scale, while notation precision controls coefficient
17
+ # digits after the leading digit.
18
+ return source.notation_precision if notation_supported
19
+
20
+ source.decimal_precision
21
+ end
22
+
23
+ private
24
+
25
+ # When precision is omitted, infer the target-base fractional digits
26
+ # needed to satisfy :significant. Cap the count to the source significant
27
+ # digits so base conversion does not invent precision for values like 0.1.
28
+ # Add one extra digit when the source has more significant digits so
29
+ # Significant can still perform the final rounding pass.
30
+ def significant_base_precision(source, base, significant)
31
+ return unless target_base?(base)
32
+
33
+ return if significant.zero?
34
+
35
+ return 0 unless source.fractional?
36
+
37
+ source_significant = source.significant_digit_count
38
+ effective_significant = [significant, source_significant].min
39
+ target_precision = [
40
+ effective_significant - source.target_base_integer_length(base),
41
+ 0,
42
+ ].max
43
+
44
+ target_precision += 1 if source_significant > effective_significant
45
+ target_precision
46
+ end
47
+
48
+ def target_base?(base)
49
+ BaseNotation::DEFAULT_PREFIXES.key?(base) && base != Base::DEFAULT_BASE
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plurimath
4
+ module Formatter
5
+ module Numbers
6
+ # Applies the final sign prefix to already-rendered number text.
7
+ class SignRenderer
8
+ def initialize(positive_sign = nil)
9
+ @positive_sign = positive_sign
10
+ end
11
+
12
+ def apply(number, rendered)
13
+ "#{prefix(number)}#{rendered}"
14
+ end
15
+
16
+ private
17
+
18
+ attr_reader :positive_sign
19
+
20
+ def prefix(number)
21
+ return "-" if number.negative?
22
+
23
+ "+" if positive_sign&.to_sym == :plus
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end