plurimath 0.11.0 → 0.11.3

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 (120) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +3 -0
  3. data/.rubocop_todo.yml +311 -58
  4. data/Gemfile +3 -2
  5. data/README.adoc +331 -15
  6. data/lib/plurimath/asciimath/parse.rb +6 -1
  7. data/lib/plurimath/asciimath/transform.rb +2 -0
  8. data/lib/plurimath/base_number_prefix.rb +43 -0
  9. data/lib/plurimath/configuration.rb +9 -1
  10. data/lib/plurimath/deprecation.rb +2 -1
  11. data/lib/plurimath/errors/configuration_error.rb +5 -1
  12. data/lib/plurimath/errors/deprecation_error.rb +2 -1
  13. data/lib/plurimath/errors/evaluation/division_by_zero_error.rb +13 -0
  14. data/lib/plurimath/errors/evaluation/error.rb +9 -0
  15. data/lib/plurimath/errors/evaluation/invalid_binding_error.rb +14 -0
  16. data/lib/plurimath/errors/evaluation/invalid_binding_key_error.rb +14 -0
  17. data/lib/plurimath/errors/evaluation/math_domain_error.rb +9 -0
  18. data/lib/plurimath/errors/evaluation/missing_variable_error.rb +13 -0
  19. data/lib/plurimath/errors/evaluation/non_finite_result_error.rb +13 -0
  20. data/lib/plurimath/errors/evaluation/unsupported_expression_error.rb +13 -0
  21. data/lib/plurimath/errors/evaluation.rb +18 -0
  22. data/lib/plurimath/errors/invalid_number.rb +17 -0
  23. data/lib/plurimath/errors/unsupported_base.rb +18 -0
  24. data/lib/plurimath/errors/{formatter/unsupported_locale.rb → unsupported_locale.rb} +1 -1
  25. data/lib/plurimath/errors.rb +9 -0
  26. data/lib/plurimath/formatter/numbers/base_notation.rb +55 -27
  27. data/lib/plurimath/formatter/numbers/format_options.rb +123 -17
  28. data/lib/plurimath/formatter/numbers/formatted_notation.rb +62 -0
  29. data/lib/plurimath/formatter/numbers/formatted_number.rb +87 -0
  30. data/lib/plurimath/formatter/numbers/fraction.rb +8 -17
  31. data/lib/plurimath/formatter/numbers/integer.rb +3 -0
  32. data/lib/plurimath/formatter/numbers/mathml_renderer.rb +56 -0
  33. data/lib/plurimath/formatter/numbers/notation_renderer.rb +58 -28
  34. data/lib/plurimath/formatter/numbers/number_renderer.rb +26 -13
  35. data/lib/plurimath/formatter/numbers/omml_renderer.rb +74 -0
  36. data/lib/plurimath/formatter/numbers/precision_resolver.rb +16 -3
  37. data/lib/plurimath/formatter/numbers/sign_renderer.rb +2 -1
  38. data/lib/plurimath/formatter/numbers/significant.rb +4 -2
  39. data/lib/plurimath/formatter/numbers/source.rb +46 -6
  40. data/lib/plurimath/formatter/numbers/text_renderer.rb +52 -0
  41. data/lib/plurimath/formatter/numbers.rb +6 -2
  42. data/lib/plurimath/formatter/standard.rb +6 -0
  43. data/lib/plurimath/formatter/supported_locales.rb +1 -1
  44. data/lib/plurimath/formatter.rb +0 -2
  45. data/lib/plurimath/html/parse.rb +5 -0
  46. data/lib/plurimath/html/transform.rb +26 -12
  47. data/lib/plurimath/latex/parse.rb +5 -0
  48. data/lib/plurimath/latex/transform.rb +2 -0
  49. data/lib/plurimath/math/core.rb +69 -14
  50. data/lib/plurimath/math/evaluation/evaluator.rb +147 -0
  51. data/lib/plurimath/math/evaluation/expression_parser.rb +215 -0
  52. data/lib/plurimath/math/evaluation/iteration.rb +63 -0
  53. data/lib/plurimath/math/evaluation.rb +13 -0
  54. data/lib/plurimath/math/formula.rb +9 -0
  55. data/lib/plurimath/math/function/abs.rb +4 -0
  56. data/lib/plurimath/math/function/arccos.rb +4 -0
  57. data/lib/plurimath/math/function/arcsin.rb +4 -0
  58. data/lib/plurimath/math/function/arctan.rb +4 -0
  59. data/lib/plurimath/math/function/ceil.rb +4 -0
  60. data/lib/plurimath/math/function/cos.rb +4 -0
  61. data/lib/plurimath/math/function/cosh.rb +4 -0
  62. data/lib/plurimath/math/function/cot.rb +4 -0
  63. data/lib/plurimath/math/function/coth.rb +4 -0
  64. data/lib/plurimath/math/function/csc.rb +4 -0
  65. data/lib/plurimath/math/function/csch.rb +4 -0
  66. data/lib/plurimath/math/function/exp.rb +4 -0
  67. data/lib/plurimath/math/function/fenced.rb +4 -0
  68. data/lib/plurimath/math/function/floor.rb +4 -0
  69. data/lib/plurimath/math/function/frac.rb +7 -0
  70. data/lib/plurimath/math/function/gcd.rb +9 -0
  71. data/lib/plurimath/math/function/lcm.rb +9 -0
  72. data/lib/plurimath/math/function/lg.rb +4 -0
  73. data/lib/plurimath/math/function/ln.rb +4 -0
  74. data/lib/plurimath/math/function/log.rb +19 -0
  75. data/lib/plurimath/math/function/max.rb +4 -0
  76. data/lib/plurimath/math/function/min.rb +4 -0
  77. data/lib/plurimath/math/function/mod.rb +15 -0
  78. data/lib/plurimath/math/function/overleftrightarrow.rb +2 -2
  79. data/lib/plurimath/math/function/power.rb +10 -0
  80. data/lib/plurimath/math/function/prod.rb +10 -0
  81. data/lib/plurimath/math/function/root.rb +7 -0
  82. data/lib/plurimath/math/function/sec.rb +4 -0
  83. data/lib/plurimath/math/function/sech.rb +4 -0
  84. data/lib/plurimath/math/function/sin.rb +4 -0
  85. data/lib/plurimath/math/function/sinh.rb +4 -0
  86. data/lib/plurimath/math/function/sqrt.rb +4 -0
  87. data/lib/plurimath/math/function/sum.rb +10 -0
  88. data/lib/plurimath/math/function/tan.rb +4 -0
  89. data/lib/plurimath/math/function/tanh.rb +4 -0
  90. data/lib/plurimath/math/function/text.rb +17 -0
  91. data/lib/plurimath/math/number.rb +44 -23
  92. data/lib/plurimath/math/symbols/cdot.rb +4 -0
  93. data/lib/plurimath/math/symbols/div.rb +4 -0
  94. data/lib/plurimath/math/symbols/hat.rb +4 -0
  95. data/lib/plurimath/math/symbols/minus.rb +4 -0
  96. data/lib/plurimath/math/symbols/pi.rb +5 -1
  97. data/lib/plurimath/math/symbols/plus.rb +4 -0
  98. data/lib/plurimath/math/symbols/slash.rb +4 -0
  99. data/lib/plurimath/math/symbols/symbol.rb +45 -0
  100. data/lib/plurimath/math/symbols/times.rb +4 -0
  101. data/lib/plurimath/math.rb +5 -1
  102. data/lib/plurimath/mathml/constants.rb +18 -0
  103. data/lib/plurimath/number_formatter.rb +63 -3
  104. data/lib/plurimath/omml/formula_transformation.rb +2 -1
  105. data/lib/plurimath/omml/translator.rb +4 -1
  106. data/lib/plurimath/setup/opal.rb.erb +13 -0
  107. data/lib/plurimath/unicode_math/parse.rb +98 -54
  108. data/lib/plurimath/unicode_math/parser.rb +7 -4
  109. data/lib/plurimath/unicode_math/parsing_rules/absence_rules.rb +33 -21
  110. data/lib/plurimath/unicode_math/parsing_rules/common_rules.rb +28 -16
  111. data/lib/plurimath/unicode_math/parsing_rules/constants_rules.rb +18 -7
  112. data/lib/plurimath/unicode_math/parsing_rules/masked.rb +35 -15
  113. data/lib/plurimath/unicode_math/parsing_rules/sub_sup.rb +98 -72
  114. data/lib/plurimath/unicode_math/transform.rb +1 -0
  115. data/lib/plurimath/utility.rb +1 -1
  116. data/lib/plurimath/version.rb +1 -1
  117. data/lib/plurimath.rb +2 -0
  118. metadata +24 -5
  119. data/lib/plurimath/errors/formatter/unsupported_base.rb +0 -21
  120. data/lib/plurimath/formatter/numbers/parts_renderer.rb +0 -30
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plurimath
4
+ module Errors
5
+ module Evaluation
6
+ class UnsupportedExpressionError < Error
7
+ def initialize(message)
8
+ super("unsupported expression: #{message}")
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plurimath
4
+ module Errors
5
+ module Evaluation
6
+ autoload :Error, "#{__dir__}/evaluation/error"
7
+ autoload :DivisionByZeroError, "#{__dir__}/evaluation/division_by_zero_error"
8
+ autoload :InvalidBindingError, "#{__dir__}/evaluation/invalid_binding_error"
9
+ autoload :InvalidBindingKeyError,
10
+ "#{__dir__}/evaluation/invalid_binding_key_error"
11
+ autoload :MathDomainError, "#{__dir__}/evaluation/math_domain_error"
12
+ autoload :MissingVariableError, "#{__dir__}/evaluation/missing_variable_error"
13
+ autoload :NonFiniteResultError, "#{__dir__}/evaluation/non_finite_result_error"
14
+ autoload :UnsupportedExpressionError,
15
+ "#{__dir__}/evaluation/unsupported_expression_error"
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plurimath
4
+ module Errors
5
+ class InvalidNumber < Plurimath::Error
6
+ def initialize(value)
7
+ @value = value
8
+ super(message)
9
+ end
10
+
11
+ def message
12
+ "[plurimath] Invalid number #{@value.inspect} for number formatting. " \
13
+ "Expected a numeric string such as \"1234\", \"-12.34\", or \"1.2e5\"."
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plurimath
4
+ module Errors
5
+ class UnsupportedBase < Plurimath::Error
6
+ def initialize(base, supported_bases)
7
+ @base = base
8
+ @supported = supported_bases.keys.join(", ")
9
+ super(message)
10
+ end
11
+
12
+ def message
13
+ "[plurimath] Unsupported base `#{@base}` for number formatting. " \
14
+ "[plurimath] The formatter `:base` option must be one of: #{@supported}."
15
+ end
16
+ end
17
+ end
18
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Plurimath
4
- module Formatter
4
+ module Errors
5
5
  class UnsupportedLocale < Plurimath::Error
6
6
  def initialize(locale, supported_locales)
7
7
  @locale = locale
@@ -1 +1,10 @@
1
1
  # frozen_string_literal: true
2
+
3
+ module Plurimath
4
+ module Errors
5
+ autoload :Evaluation, "#{__dir__}/errors/evaluation"
6
+ autoload :InvalidNumber, "#{__dir__}/errors/invalid_number"
7
+ autoload :UnsupportedBase, "#{__dir__}/errors/unsupported_base"
8
+ autoload :UnsupportedLocale, "#{__dir__}/errors/unsupported_locale"
9
+ end
10
+ end
@@ -3,8 +3,10 @@
3
3
  module Plurimath
4
4
  module Formatter
5
5
  module Numbers
6
- # Applies base prefix/postfix notation after numeric digits have already
7
- # been rendered.
6
+ # Describes base prefix/postfix notation as a resolved value object.
7
+ # Constructed from FormatOptions via .from_options; carries the resolved
8
+ # prefix and postfix strings so renderers can decide how to represent the
9
+ # base (inline text, MathML subscript, LaTeX subscript, etc.).
8
10
  class BaseNotation
9
11
  DEFAULT_PREFIXES = {
10
12
  2 => "0b",
@@ -13,54 +15,80 @@ module Plurimath
13
15
  16 => "0x",
14
16
  }.freeze
15
17
 
16
- attr_reader :base
18
+ attr_reader :base, :prefix, :postfix, :hex_capital
17
19
 
18
- def initialize(options)
19
- @options = options
20
- @base = @options.base
21
- validate_base!
20
+ def initialize(base:, prefix: "", postfix: "", hex_capital: nil,
21
+ explicit_prefix: false, explicit_postfix: false)
22
+ @base = base
23
+ @prefix = prefix
24
+ @postfix = postfix
25
+ @hex_capital = hex_capital
26
+ @explicit_prefix = explicit_prefix
27
+ @explicit_postfix = explicit_postfix
22
28
  end
23
29
 
24
- def apply(string)
25
- rendered = upcase_hex? ? string.tr(Base::HEX_DIGITS, Base::HEX_DIGITS.upcase) : string
26
- return rendered if default?
30
+ def self.from_options(options)
31
+ base = options.base
32
+ validate!(base)
27
33
 
28
- "#{base_prefix}#{rendered}#{base_postfix}"
34
+ new(
35
+ base: base,
36
+ prefix: resolve_prefix(options, base),
37
+ postfix: options.base_postfix.to_s,
38
+ hex_capital: options.hex_capital,
39
+ explicit_prefix: options.base_prefix?,
40
+ explicit_postfix: options.base_postfix?,
41
+ )
42
+ end
43
+
44
+ def self.supported?(base)
45
+ DEFAULT_PREFIXES.key?(base)
29
46
  end
30
47
 
31
48
  def default?
32
49
  base == Base::DEFAULT_BASE
33
50
  end
34
51
 
35
- def self.supported?(base)
36
- DEFAULT_PREFIXES.key?(base)
52
+ # Caller overrode prefix or postfix at format-call time. Renderers that
53
+ # honor semantic base notation (msub, \mathrm{}_{base}, _(base)) must
54
+ # defer to the literal prefix/postfix in that case.
55
+ def literal?
56
+ !default? && (explicit_prefix? || explicit_postfix?)
57
+ end
58
+
59
+ def semantic?
60
+ !default? && !literal?
61
+ end
62
+
63
+ def upcase_hex?
64
+ base == 16 && hex_capital == true
65
+ end
66
+
67
+ def wrap(digits)
68
+ "#{prefix}#{digits}#{postfix}"
37
69
  end
38
70
 
39
71
  private
40
72
 
41
- attr_reader :options
73
+ attr_reader :explicit_prefix, :explicit_postfix
42
74
 
43
- def base_prefix
75
+ alias explicit_prefix? explicit_prefix
76
+ alias explicit_postfix? explicit_postfix
77
+
78
+ def self.resolve_prefix(options, base)
44
79
  return options.base_prefix if options.base_prefix?
45
- # A postfix without an explicit prefix opts out of the default prefix.
46
80
  return "" if options.base_postfix?
47
81
 
48
82
  DEFAULT_PREFIXES[base]
49
83
  end
84
+ private_class_method :resolve_prefix
50
85
 
51
- def base_postfix
52
- options.base_postfix.to_s
53
- end
54
-
55
- def upcase_hex?
56
- base == 16 && options.hex_capital == true
57
- end
58
-
59
- def validate_base!
60
- return if self.class.supported?(base)
86
+ def self.validate!(base)
87
+ return if supported?(base)
61
88
 
62
- raise UnsupportedBase.new(base, DEFAULT_PREFIXES)
89
+ raise Plurimath::Errors::UnsupportedBase.new(base, DEFAULT_PREFIXES)
63
90
  end
91
+ private_class_method :validate!
64
92
  end
65
93
  end
66
94
  end
@@ -32,11 +32,16 @@ module Plurimath
32
32
  end
33
33
 
34
34
  def base
35
- symbols[:base] || Base::DEFAULT_BASE
35
+ value = symbols[:base]
36
+ return Base::DEFAULT_BASE if value.nil?
37
+
38
+ # Numeric String/Symbol forms are normalized; anything else is left
39
+ # raw so BaseNotation reports it through UnsupportedBase.
40
+ coerce_integer(value) { return value }
36
41
  end
37
42
 
38
43
  def base_prefix
39
- symbols[:base_prefix].to_s
44
+ separator_option(:base_prefix).to_s
40
45
  end
41
46
 
42
47
  def base_prefix?
@@ -44,7 +49,7 @@ module Plurimath
44
49
  end
45
50
 
46
51
  def base_postfix
47
- symbols[:base_postfix]
52
+ separator_option(:base_postfix)
48
53
  end
49
54
 
50
55
  def base_postfix?
@@ -52,56 +57,84 @@ module Plurimath
52
57
  end
53
58
 
54
59
  def decimal
55
- symbols.fetch(:decimal, DEFAULT_DECIMAL)
60
+ # An explicitly passed nil renders without a separator; output
61
+ # correctness is then the caller's responsibility.
62
+ separator_option(:decimal, default: DEFAULT_DECIMAL)
56
63
  end
57
64
 
58
65
  def digit_count
59
- symbols[:digit_count].to_i
66
+ integer_option(:digit_count, default: 0)
60
67
  end
61
68
 
62
69
  def fraction_group
63
- symbols[:fraction_group].to_s
70
+ separator_option(:fraction_group).to_s
64
71
  end
65
72
 
66
73
  def fraction_group_digits
67
- symbols[:fraction_group_digits]
74
+ integer_option(:fraction_group_digits)
68
75
  end
69
76
 
70
77
  def group
71
- symbols[:group] || DEFAULT_GROUP
78
+ # Unlike decimal, an explicit nil group falls back to the default
79
+ # separator; use "" to disable grouping.
80
+ (separator_option(:group,
81
+ default: DEFAULT_GROUP) || DEFAULT_GROUP).to_s
72
82
  end
73
83
 
74
84
  def group_digits
75
- symbols[:group_digits] || DEFAULT_GROUP_DIGITS
85
+ integer_option(:group_digits, default: DEFAULT_GROUP_DIGITS)
76
86
  end
77
87
 
78
88
  def hex_capital
79
- symbols[:hex_capital]
89
+ value = symbols[:hex_capital]
90
+ # nil is handled first: under Opal `when nil` spuriously matches
91
+ # non-nil values (nil === 1.5 is true there), so it must not appear
92
+ # in the type whitelist below.
93
+ return if value.nil?
94
+
95
+ # Accept only Boolean/String/Symbol and reject any other type.
96
+ # Attribute-parsing callers deliver String/Symbol (:true,
97
+ # "numbers_only"), so the accepted forms normalize through to_s.
98
+ case value
99
+ when true, false, String, Symbol
100
+ case value.to_s
101
+ when "true" then true
102
+ when "numbers_only" then :numbers_only
103
+ end
104
+ else
105
+ invalid_option!(:hex_capital, value)
106
+ end
80
107
  end
81
108
 
82
109
  def number_sign
83
- symbols[:number_sign]
110
+ symbol_option(:number_sign)
84
111
  end
85
112
 
86
113
  def padding
87
- value = symbols.fetch(:padding, DEFAULT_PADDING).to_s
114
+ value = separator_option(:padding, default: DEFAULT_PADDING).to_s
88
115
  value.empty? ? DEFAULT_PADDING : value[0]
89
116
  end
90
117
 
91
118
  def padding_digits
92
- symbols[:padding_digits].to_i
119
+ integer_option(:padding_digits, default: 0)
93
120
  end
94
121
 
95
122
  def padding_group_digits
96
- symbols[:padding_group_digits].to_i
123
+ integer_option(:padding_group_digits, default: 0)
97
124
  end
98
125
 
99
126
  def notation_supported?
100
127
  NotationRenderer.supported?(notation)
101
128
  end
102
129
 
130
+ # Whether precision came from the caller (kwarg or symbols) rather
131
+ # than being inferred from the source by the resolver.
132
+ def explicit_precision?
133
+ @explicit_precision
134
+ end
135
+
103
136
  def significant
104
- symbols[:significant].to_i
137
+ integer_option(:significant, default: 0)
105
138
  end
106
139
 
107
140
  def to_h
@@ -111,7 +144,19 @@ module Plurimath
111
144
  private
112
145
 
113
146
  def resolve_precision(source, precision, precision_resolver)
114
- effective_precision = precision || symbols[:precision]
147
+ # A nil check (not ||) so that explicit false reaches validation.
148
+ effective_precision = precision.nil? ? symbols[:precision] : precision
149
+ unless effective_precision.nil?
150
+ value = effective_precision
151
+ effective_precision = coerce_integer(value) do
152
+ invalid_option!(:precision, value)
153
+ end
154
+ if effective_precision.negative?
155
+ invalid_option!(:precision, value,
156
+ expected: "a non-negative integer")
157
+ end
158
+ end
159
+ @explicit_precision = !effective_precision.nil?
115
160
  return effective_precision unless precision_resolver
116
161
 
117
162
  precision_resolver.resolve(
@@ -119,12 +164,73 @@ module Plurimath
119
164
  precision: effective_precision,
120
165
  base: base,
121
166
  significant: significant,
167
+ digit_count: digit_count,
122
168
  notation_supported: notation_supported?,
123
169
  )
124
170
  end
125
171
 
172
+ # Counts must be non-negative Integers; numeric String/Symbol forms are
173
+ # normalized because attribute-parsing callers deliver those.
174
+ def integer_option(key, default: nil)
175
+ value = symbols[key]
176
+ return default if value.nil?
177
+
178
+ integer = coerce_integer(value) { invalid_option!(key, value) }
179
+ if integer.negative?
180
+ invalid_option!(key, value,
181
+ expected: "a non-negative integer")
182
+ end
183
+
184
+ integer
185
+ end
186
+
187
+ def coerce_integer(value)
188
+ # Note on Opal: JS has a single number type, so a whole-valued Float
189
+ # (4.0) is indistinguishable from the Integer 4 (same value, same
190
+ # to_s "4") — under Opal it is treated as that integer. MRI rejects
191
+ # Floats here; non-whole Floats (1.5) are rejected in both runtimes.
192
+ case value
193
+ when ::Integer then value
194
+ when true, false, Float then yield
195
+ else
196
+ begin
197
+ Integer(value.to_s, 10)
198
+ rescue ArgumentError, TypeError
199
+ yield
200
+ end
201
+ end
202
+ end
203
+
204
+ # Separators are free-form strings; explicit nil disables the
205
+ # separator, and Booleans are always a caller bug.
206
+ def separator_option(key, default: nil)
207
+ value = symbols.fetch(key, default)
208
+ invalid_option!(key, value) if [true, false].include?(value)
209
+
210
+ value
211
+ end
212
+
126
213
  def symbol_option(key)
127
- symbols[key]&.to_sym
214
+ value = symbols[key]
215
+ return if value.nil?
216
+
217
+ # Enumerated/symbol options accept only String or Symbol; reject
218
+ # other types (and booleans) instead of coercing arbitrary input.
219
+ unless value.is_a?(String) || value.is_a?(Symbol)
220
+ invalid_option!(key,
221
+ value)
222
+ end
223
+
224
+ value.to_sym
225
+ end
226
+
227
+ def invalid_option!(key, value, expected: nil)
228
+ raise Plurimath::ConfigurationError.new(
229
+ :invalid_formatter_option,
230
+ option: key,
231
+ value: value,
232
+ supported: expected,
233
+ )
128
234
  end
129
235
 
130
236
  def validate_padding_options!
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plurimath
4
+ module Formatter
5
+ module Numbers
6
+ # Structured result of notation formatting (e, scientific, engineering).
7
+ # Carries the coefficient as a FormattedNumber, the exponent as an
8
+ # integer, and notation style metadata so output renderers (MathML,
9
+ # LaTeX, etc.) can produce structured representations instead of flat
10
+ # strings.
11
+ class FormattedNotation
12
+ STYLES = %i[e scientific engineering].freeze
13
+
14
+ attr_reader :coefficient, :notation_style, :exponent,
15
+ :times_symbol, :exponent_separator, :exponent_sign
16
+
17
+ def initialize(
18
+ coefficient:,
19
+ notation_style:,
20
+ exponent:,
21
+ times_symbol: "×",
22
+ exponent_separator: "e",
23
+ exponent_sign: nil
24
+ )
25
+ @coefficient = coefficient
26
+ @notation_style = notation_style
27
+ @exponent = exponent
28
+ @times_symbol = times_symbol
29
+ @exponent_separator = exponent_separator
30
+ @exponent_sign = exponent_sign
31
+ end
32
+
33
+ def to_s
34
+ case notation_style
35
+ when :e
36
+ "#{coefficient}#{exponent_separator}#{formatted_exponent}"
37
+ when :scientific, :engineering
38
+ "#{coefficient} #{times_symbol} 10^#{formatted_exponent}"
39
+ end
40
+ end
41
+
42
+ def to_str
43
+ to_s
44
+ end
45
+
46
+ def base_notation?
47
+ coefficient.base_notation?
48
+ end
49
+
50
+ # The exponent rendered as a string, applying sign conventions.
51
+ # Public so that output renderers can access it directly.
52
+ def formatted_exponent
53
+ return "0" if exponent.zero?
54
+
55
+ sign_prefix = exponent_sign == :plus ? "+" : nil
56
+ abs_exp = exponent.abs.to_s
57
+ exponent.negative? ? "-#{abs_exp}" : "#{sign_prefix}#{abs_exp}"
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plurimath
4
+ module Formatter
5
+ module Numbers
6
+ # Structured result of number formatting. Carries sign, digit parts,
7
+ # decimal separator, and base notation as separate semantic elements
8
+ # so output renderers (MathML, LaTeX, etc.) can produce structured
9
+ # representations instead of flat strings.
10
+ class FormattedNumber
11
+ attr_reader :sign, :integer_part, :fraction_part,
12
+ :decimal_separator, :base_notation, :number_sign
13
+
14
+ def initialize(
15
+ sign:,
16
+ integer_part:,
17
+ fraction_part:,
18
+ decimal_separator:,
19
+ base_notation:,
20
+ number_sign: nil
21
+ )
22
+ @sign = sign
23
+ @integer_part = integer_part
24
+ @fraction_part = fraction_part
25
+ @decimal_separator = decimal_separator
26
+ @base_notation = base_notation
27
+ @number_sign = number_sign
28
+ end
29
+
30
+ def negative?
31
+ sign == -1
32
+ end
33
+
34
+ def fractional?
35
+ !fraction_part.empty?
36
+ end
37
+
38
+ def base_notation?
39
+ !base_notation.default?
40
+ end
41
+
42
+ # The sign as a rendering prefix: "-" for negative, "+" when
43
+ # number_sign is :plus, nil otherwise. Output renderers use this
44
+ # to produce format-specific sign elements.
45
+ def sign_text
46
+ if negative?
47
+ "-"
48
+ elsif number_sign == :plus
49
+ "+"
50
+ end
51
+ end
52
+
53
+ def to_s
54
+ digits = formatted_digits
55
+ digits = base_notation.wrap(digits) unless base_notation.default?
56
+ "#{sign_text}#{digits}"
57
+ end
58
+
59
+ def to_str
60
+ to_s
61
+ end
62
+
63
+ # Digits with decimal separator and optional hex capitalization,
64
+ # but without sign, prefix, or postfix. Used by structured renderers
65
+ # that handle sign and base notation as separate elements.
66
+ def digits_string
67
+ formatted_digits
68
+ end
69
+
70
+ private
71
+
72
+ def formatted_digits
73
+ digits = assembled_digits
74
+ base_notation.upcase_hex? ? upcase_hex(digits) : digits
75
+ end
76
+
77
+ def assembled_digits
78
+ fractional? ? "#{integer_part}#{decimal_separator}#{fraction_part}" : integer_part
79
+ end
80
+
81
+ def upcase_hex(string)
82
+ string.tr(Base::HEX_DIGITS, Base::HEX_DIGITS.upcase)
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -20,7 +20,7 @@ module Plurimath
20
20
  end
21
21
 
22
22
  # Keep fraction preparation on structured parts; localized rendering and
23
- # grouping happen later at the PartsRenderer boundary.
23
+ # grouping happen later at the FormattedNumber boundary.
24
24
  def apply_parts(parts, precision: self.precision)
25
25
  precision = precision.to_i
26
26
  return parts.with_digits(fraction_digits: DEFAULT_STRINGS[:empty]) unless precision.positive?
@@ -28,9 +28,12 @@ module Plurimath
28
28
  @integer_digits = parts.integer_digits
29
29
 
30
30
  fraction = parts.fraction_digits
31
- fraction = change_base(fraction, precision) if !base_default? && fraction.match?(/[1-9]/)
31
+ if !base_default? && fraction.match?(/[1-9]/)
32
+ fraction = change_base(fraction,
33
+ precision)
34
+ end
32
35
  number = if @digit_count.positive?
33
- digit_count_format(fraction, precision)
36
+ digit_count_format(fraction)
34
37
  else
35
38
  format(fraction, precision)
36
39
  end
@@ -67,7 +70,7 @@ module Plurimath
67
70
 
68
71
  # The digit_count option is a total visible-digit budget, so fraction
69
72
  # rounding can carry back into the integer digits.
70
- def digit_count_format(fraction, precision)
73
+ def digit_count_format(fraction)
71
74
  integer = integer_digits + DEFAULT_STRINGS[:dot] + fraction
72
75
  int_length = integer.length.pred # integer length; excluding the decimal point
73
76
  if int_length > @digit_count
@@ -79,24 +82,12 @@ module Plurimath
79
82
  end
80
83
  round_base_string(fraction)
81
84
  elsif int_length < @digit_count
82
- fraction + (DEFAULT_STRINGS[:zero] * (update_digit_count(fraction, precision) - int_length))
85
+ fraction + (DEFAULT_STRINGS[:zero] * (@digit_count - int_length))
83
86
  else
84
87
  fraction
85
88
  end
86
89
  end
87
90
 
88
- def update_digit_count(number, precision)
89
- return @digit_count unless zeros_count_in(number) == precision
90
-
91
- @digit_count - precision + 1
92
- end
93
-
94
- def zeros_count_in(number)
95
- return unless number.chars.all?(DEFAULT_STRINGS[:zero])
96
-
97
- number.length
98
- end
99
-
100
91
  def round_base_string(fraction)
101
92
  digits = fraction[0..frac_digit_count].chars
102
93
  discard_char = digits.pop
@@ -24,6 +24,9 @@ module Plurimath
24
24
  def format_groups(string)
25
25
  string = capitalize_hex_digits(string)
26
26
  string = pad_integer(string)
27
+ # group_digits: 0 disables grouping, mirroring fraction grouping.
28
+ return string unless groups.positive?
29
+
27
30
  tokens = []
28
31
 
29
32
  until string.empty?