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.
- checksums.yaml +4 -4
- data/.github/workflows/release.yml +3 -2
- data/README.adoc +343 -44
- data/lib/plurimath/asciimath/parse.rb +6 -2
- data/lib/plurimath/configuration.rb +17 -0
- data/lib/plurimath/deprecation.rb +81 -0
- data/lib/plurimath/errors/configuration_error.rb +27 -0
- data/lib/plurimath/errors/deprecation_error.rb +33 -0
- data/lib/plurimath/errors/error.rb +6 -0
- data/lib/plurimath/errors/formatter/unsupported_base.rb +1 -1
- data/lib/plurimath/errors/formatter/unsupported_locale.rb +18 -0
- data/lib/plurimath/errors/omml/unsupported_node_error.rb +1 -1
- data/lib/plurimath/errors/parse_error.rb +1 -1
- data/lib/plurimath/errors/parse_option_error.rb +34 -0
- data/lib/plurimath/formatter/numbers/base.rb +18 -8
- data/lib/plurimath/formatter/numbers/base_notation.rb +67 -0
- data/lib/plurimath/formatter/numbers/digit_sequence.rb +96 -0
- data/lib/plurimath/formatter/numbers/format_options.rb +141 -0
- data/lib/plurimath/formatter/numbers/fraction.rb +50 -93
- data/lib/plurimath/formatter/numbers/integer.rb +30 -6
- data/lib/plurimath/formatter/numbers/notation_renderer.rb +128 -0
- data/lib/plurimath/formatter/numbers/number_renderer.rb +66 -0
- data/lib/plurimath/formatter/numbers/parts.rb +69 -0
- data/lib/plurimath/formatter/numbers/parts_renderer.rb +30 -0
- data/lib/plurimath/formatter/numbers/precision_resolver.rb +54 -0
- data/lib/plurimath/formatter/numbers/sign_renderer.rb +28 -0
- data/lib/plurimath/formatter/numbers/significant.rb +77 -103
- data/lib/plurimath/formatter/numbers/source.rb +120 -0
- data/lib/plurimath/formatter/numbers/symbol_resolver.rb +55 -0
- data/lib/plurimath/formatter/numbers.rb +11 -0
- data/lib/plurimath/formatter/standard.rb +32 -42
- data/lib/plurimath/formatter/supported_locales.rb +27 -0
- data/lib/plurimath/formatter.rb +1 -2
- data/lib/plurimath/html/constants.rb +2 -0
- data/lib/plurimath/html/parse.rb +77 -14
- data/lib/plurimath/html/parser.rb +15 -3
- data/lib/plurimath/html/transform.rb +193 -91
- data/lib/plurimath/html/transform_utility.rb +61 -0
- data/lib/plurimath/html.rb +1 -0
- data/lib/plurimath/latex/parse.rb +7 -1
- data/lib/plurimath/latex/transform.rb +5 -5
- data/lib/plurimath/math/function/lim.rb +6 -0
- data/lib/plurimath/math/number.rb +8 -2
- data/lib/plurimath/math/symbols/cdot.rb +1 -1
- data/lib/plurimath/math/symbols/exclam.rb +1 -1
- data/lib/plurimath/math/symbols/minus.rb +1 -1
- data/lib/plurimath/math/symbols/percent.rb +1 -1
- data/lib/plurimath/math/symbols/pi.rb +1 -1
- data/lib/plurimath/math/symbols/slash.rb +1 -1
- data/lib/plurimath/math.rb +56 -8
- data/lib/plurimath/number_formatter.rb +57 -27
- data/lib/plurimath/unicode_math/parse.rb +7 -1
- data/lib/plurimath/unicode_math/transform.rb +2 -2
- data/lib/plurimath/version.rb +1 -1
- data/lib/plurimath.rb +23 -1
- metadata +21 -4
- data/lib/plurimath/formatter/number_formatter.rb +0 -115
- 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 =
|
|
10
|
-
DEFAULT_STRINGS = { empty: "", zero: "0", dot: "."
|
|
10
|
+
DEFAULT_PRECISION = FormatOptions::DEFAULT_FRACTION_PRECISION
|
|
11
|
+
DEFAULT_STRINGS = { empty: "", zero: "0", dot: "." }.freeze
|
|
11
12
|
|
|
12
|
-
def initialize(
|
|
13
|
+
def initialize(options)
|
|
13
14
|
super
|
|
14
|
-
@group
|
|
15
|
-
@decimal
|
|
16
|
-
@
|
|
17
|
-
@
|
|
18
|
-
@
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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
|
|
67
|
-
#
|
|
68
|
-
if @digit_count <=
|
|
69
|
-
|
|
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) ==
|
|
88
|
+
def update_digit_count(number, precision)
|
|
89
|
+
return @digit_count unless zeros_count_in(number) == precision
|
|
86
90
|
|
|
87
|
-
@digit_count -
|
|
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
|
-
|
|
122
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
|
187
|
-
@
|
|
143
|
+
def integer_digits
|
|
144
|
+
@integer_digits
|
|
188
145
|
end
|
|
189
146
|
|
|
190
147
|
def frac_digit_count
|
|
191
|
-
@digit_count -
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
def initialize(symbols = {})
|
|
11
|
+
def initialize(options)
|
|
12
12
|
super
|
|
13
|
-
@groups
|
|
14
|
-
@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
|