plurimath 0.11.0 → 0.11.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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +354 -34
  3. data/Gemfile +2 -2
  4. data/README.adoc +49 -9
  5. data/lib/plurimath/deprecation.rb +2 -1
  6. data/lib/plurimath/errors/configuration_error.rb +5 -1
  7. data/lib/plurimath/errors/deprecation_error.rb +2 -1
  8. data/lib/plurimath/errors/invalid_number.rb +17 -0
  9. data/lib/plurimath/errors/unsupported_base.rb +18 -0
  10. data/lib/plurimath/errors/{formatter/unsupported_locale.rb → unsupported_locale.rb} +1 -1
  11. data/lib/plurimath/errors.rb +8 -0
  12. data/lib/plurimath/formatter/numbers/base_notation.rb +7 -2
  13. data/lib/plurimath/formatter/numbers/format_options.rb +123 -17
  14. data/lib/plurimath/formatter/numbers/fraction.rb +7 -16
  15. data/lib/plurimath/formatter/numbers/integer.rb +3 -0
  16. data/lib/plurimath/formatter/numbers/notation_renderer.rb +35 -6
  17. data/lib/plurimath/formatter/numbers/number_renderer.rb +16 -4
  18. data/lib/plurimath/formatter/numbers/precision_resolver.rb +16 -3
  19. data/lib/plurimath/formatter/numbers/sign_renderer.rb +2 -1
  20. data/lib/plurimath/formatter/numbers/significant.rb +4 -2
  21. data/lib/plurimath/formatter/numbers/source.rb +18 -3
  22. data/lib/plurimath/formatter/standard.rb +6 -0
  23. data/lib/plurimath/formatter/supported_locales.rb +1 -1
  24. data/lib/plurimath/formatter.rb +0 -2
  25. data/lib/plurimath/html/transform.rb +24 -12
  26. data/lib/plurimath/math/core.rb +17 -14
  27. data/lib/plurimath/math/function/overleftrightarrow.rb +2 -2
  28. data/lib/plurimath/math/number.rb +11 -1
  29. data/lib/plurimath/math.rb +4 -1
  30. data/lib/plurimath/number_formatter.rb +45 -4
  31. data/lib/plurimath/omml/formula_transformation.rb +2 -1
  32. data/lib/plurimath/omml/translator.rb +4 -1
  33. data/lib/plurimath/unicode_math/parse.rb +93 -53
  34. data/lib/plurimath/unicode_math/parser.rb +7 -4
  35. data/lib/plurimath/unicode_math/parsing_rules/absence_rules.rb +33 -21
  36. data/lib/plurimath/unicode_math/parsing_rules/common_rules.rb +28 -16
  37. data/lib/plurimath/unicode_math/parsing_rules/constants_rules.rb +18 -7
  38. data/lib/plurimath/unicode_math/parsing_rules/masked.rb +35 -15
  39. data/lib/plurimath/unicode_math/parsing_rules/sub_sup.rb +98 -72
  40. data/lib/plurimath/unicode_math/transform.rb +755 -468
  41. data/lib/plurimath/version.rb +1 -1
  42. data/lib/plurimath.rb +1 -0
  43. metadata +5 -4
  44. data/lib/plurimath/errors/formatter/unsupported_base.rb +0 -21
data/README.adoc CHANGED
@@ -513,7 +513,7 @@ formatter = Plurimath::NumberFormatter.new(
513
513
  <1> Locale to be used for number formatting.
514
514
  <2> String pattern to define the number formatting.
515
515
  <3> Hash containing relevant options for number formatting.
516
- <4> Number of decimal places to round.
516
+ <4> Number of decimal places to keep; the fraction is truncated, not rounded.
517
517
 
518
518
  Where,
519
519
 
@@ -534,7 +534,8 @@ options for number formatting. Use either `localize_number` or
534
534
  See <<localizer_symbols,format options hash>> for details.
535
535
 
536
536
  `precision: <precision-number>`:: (optional, default `nil`)
537
- Number of decimal places to round. Accepts an integer value.
537
+ Number of decimal places to keep; the fraction is truncated, not
538
+ rounded. Accepts an integer value.
538
539
  +
539
540
  .Specifying a precision of 6 digits
540
541
  [example]
@@ -571,6 +572,28 @@ through a Hash object containing specified options.
571
572
 
572
573
  This Hash object is called the "format options Hash".
573
574
 
575
+ Option values follow a common handling policy:
576
+
577
+ * Count options (`precision`, `significant`, `digit_count`, `group_digits`,
578
+ `fraction_group_digits`, `padding_digits`, `padding_group_digits`) accept an
579
+ `Integer` or its `String`/`Symbol` numeric equivalent (`"4"`). Any other
580
+ value, including negative numbers, raises `Plurimath::ConfigurationError`.
581
+ * Separator options (`decimal`, `group`, `fraction_group`, `base_prefix`,
582
+ `base_postfix`) accept free-form strings. An explicitly passed `nil`
583
+ disables the separator — for `decimal` this renders the digits without a
584
+ decimal mark, and output correctness is then the caller's responsibility.
585
+ One exception: `group: nil` falls back to the default separator instead of
586
+ disabling (use `group: ""` to disable grouping). Boolean values raise
587
+ `Plurimath::ConfigurationError`.
588
+ * Enumerated options (`notation`, `number_sign`, `exponent_sign`, `e`,
589
+ `times`, `hex_capital`) accept their documented values as `Symbol` or
590
+ `String`; unrecognized `String`/`Symbol` values fall back to the default
591
+ behavior. Any other type raises `Plurimath::ConfigurationError` — including
592
+ Boolean, except for `hex_capital`, where `true`/`false` are documented values.
593
+ * The `locale` always falls back to `:en` for unsupported or wrong-typed
594
+ values; it never raises.
595
+ * Invalid number input raises `Plurimath::Errors::InvalidNumber`.
596
+
574
597
  Available options are explained below.
575
598
 
576
599
  NOTE: Each option takes an input of a certain specified type. Using an input
@@ -584,6 +607,15 @@ The same option keys can be passed through `localizer_symbols`, through the
584
607
  per-call `format:` hash, or through `Plurimath::Formatter::Standard`'s
585
608
  `options:` argument.
586
609
 
610
+ When rendering through `Plurimath::Math::Formula` serialization methods
611
+ (`to_asciimath`, `to_latex`, `to_mathml`, `to_unicodemath`, `to_html`),
612
+ per-element overrides can be supplied via `options[:format]`. The hash is
613
+ forwarded to `NumberFormatter#localized_number`'s `format:` keyword, taking
614
+ precedence over the configured formatter's symbols for that one element.
615
+ Custom formatters with a simpler `localized_number(value)` signature are
616
+ unaffected unless `options[:format]` is supplied, in which case their method
617
+ must accept the `format:` keyword.
618
+
587
619
 
588
620
  `decimal`:: (`String` value)
589
621
  Symbol to use for the decimal point. Accepts a character.
@@ -695,12 +727,12 @@ Missing integer digits are inserted before grouping using the configured
695
727
  "32" with `padding_group_digits: 4, group: ""` => "0032"
696
728
  "32123" with `padding_group_digits: 4, group: ""` => "00032123"
697
729
  ====
698
- ====
699
730
 
700
731
  `base`:: (`Numeric` value)
701
732
  Sets the numeric base (radix) used to render both the integer and fractional parts of the number.
702
733
  Supported values are 2 (binary), 8 (octal), 10 (decimal, default) and 16 (hexadecimal).
703
- Passing any other value for `base` raises `Plurimath::Formatter::UnsupportedBase`.
734
+ The value is also accepted as its `String` or `Symbol` equivalent (`"16"`).
735
+ Passing any other value for `base` raises `Plurimath::Errors::UnsupportedBase`.
704
736
  +
705
737
  .Rendering numbers in different bases
706
738
  [example]
@@ -752,6 +784,10 @@ uppercase letters. This includes both hexadecimal digits and any separators
752
784
  Use `:numbers_only` to uppercase generated hexadecimal digits without changing
753
785
  separator characters. It has no effect for other bases.
754
786
  +
787
+ Both values are also accepted as their `String` or `Symbol` equivalents
788
+ (`"true"`, `:true`, `"numbers_only"`). Matching is case-sensitive; any other
789
+ value (including any form of `false`) disables uppercasing.
790
+ +
755
791
  .Uppercase hexadecimal output
756
792
  [example]
757
793
  ====
@@ -1027,8 +1063,11 @@ In this example it is `' '` (a space).
1027
1063
  ----
1028
1064
  formatter = Plurimath::NumberFormatter.new(:en, localize_number: "#,##0.### ###")
1029
1065
  formatter.localized_number("1234.56789")
1030
- # => "1,234.568 9"
1066
+ # => "1,234.567 89"
1031
1067
  ----
1068
+
1069
+ NOTE: A space used as a group or fraction-group delimiter in the
1070
+ `localize_number` pattern is emitted as U+00A0 NO-BREAK SPACE in the output.
1032
1071
  ====
1033
1072
 
1034
1073
 
@@ -1052,7 +1091,7 @@ formatter.localized_number(
1052
1091
  ----
1053
1092
  <1> The number to be formatted.
1054
1093
  <2> The locale to be used for number formatting.
1055
- <3> The number of decimal places to round the number to.
1094
+ <3> The number of decimal places to keep; the fraction is truncated, not rounded.
1056
1095
  <4> Hash containing the relevant options for number formatting.
1057
1096
 
1058
1097
  Where,
@@ -1065,8 +1104,8 @@ Value is a symbol.
1065
1104
  Overrides the locale set during the creation of the `NumberFormatter` object. If
1066
1105
  not provided, the locale of the `NumberFormatter` instance will be used.
1067
1106
 
1068
- `precision: <precision-number>`:: (optional) The number of decimal places to round the
1069
- number to. If not provided, the precision of the `NumberFormatter` instance will
1107
+ `precision: <precision-number>`:: (optional) The number of decimal places to keep
1108
+ (the fraction is truncated, not rounded). If not provided, the precision of the `NumberFormatter` instance will
1070
1109
  be used.
1071
1110
 
1072
1111
  `format: <format-hash>`:: (optional, default `{}`) A Hash containing the relevant
@@ -1075,7 +1114,8 @@ configuration of the `NumberFormatter`.
1075
1114
  Takes a Hash in the form of the <<localizer_symbols,format options hash>>.
1076
1115
 
1077
1116
  `precision: <precision-number>`::
1078
- Number of decimal places to round. Accepts an integer value.
1117
+ Number of decimal places to keep; the fraction is truncated, not
1118
+ rounded. Accepts an integer value.
1079
1119
  +
1080
1120
  .Specifying a precision of 6 digits
1081
1121
  [example]
@@ -6,7 +6,8 @@ module Plurimath
6
6
  DEFAULT_BEHAVIOR = :collect
7
7
 
8
8
  class << self
9
- def warn(feature:, message: nil, replacement: nil, since: nil, remove_in: nil)
9
+ def warn(feature:, message: nil, replacement: nil, since: nil,
10
+ remove_in: nil)
10
11
  feature = validate_feature(feature)
11
12
  return if behavior == :collect && emitted_features[feature]
12
13
 
@@ -2,10 +2,11 @@
2
2
 
3
3
  module Plurimath
4
4
  class ConfigurationError < Error
5
- def initialize(type, value: nil, supported: nil)
5
+ def initialize(type, value: nil, supported: nil, option: nil)
6
6
  @type = type
7
7
  @value = value
8
8
  @supported = supported
9
+ @option = option
9
10
  super(message)
10
11
  end
11
12
 
@@ -19,6 +20,9 @@ module Plurimath
19
20
  when :conflicting_formatter_options
20
21
  "formatter options cannot be used together: choose either " \
21
22
  ":padding_digits or :padding_group_digits"
23
+ when :invalid_formatter_option
24
+ "invalid value #{@value.inspect} for formatter option " \
25
+ "#{@option.inspect}#{" (expected #{@supported})" if @supported}"
22
26
  else
23
27
  "invalid Plurimath configuration"
24
28
  end
@@ -6,7 +6,8 @@ module Plurimath
6
6
 
7
7
  attr_reader :feature, :replacement, :since, :remove_in
8
8
 
9
- def initialize(feature:, message: nil, replacement: nil, since: nil, remove_in: nil)
9
+ def initialize(feature:, message: nil, replacement: nil, since: nil,
10
+ remove_in: nil)
10
11
  @feature = feature
11
12
  @replacement = replacement
12
13
  @since = since
@@ -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,9 @@
1
1
  # frozen_string_literal: true
2
+
3
+ module Plurimath
4
+ module Errors
5
+ autoload :InvalidNumber, "#{__dir__}/errors/invalid_number"
6
+ autoload :UnsupportedBase, "#{__dir__}/errors/unsupported_base"
7
+ autoload :UnsupportedLocale, "#{__dir__}/errors/unsupported_locale"
8
+ end
9
+ end
@@ -22,7 +22,12 @@ module Plurimath
22
22
  end
23
23
 
24
24
  def apply(string)
25
- rendered = upcase_hex? ? string.tr(Base::HEX_DIGITS, Base::HEX_DIGITS.upcase) : string
25
+ rendered = if upcase_hex?
26
+ string.tr(Base::HEX_DIGITS,
27
+ Base::HEX_DIGITS.upcase)
28
+ else
29
+ string
30
+ end
26
31
  return rendered if default?
27
32
 
28
33
  "#{base_prefix}#{rendered}#{base_postfix}"
@@ -59,7 +64,7 @@ module Plurimath
59
64
  def validate_base!
60
65
  return if self.class.supported?(base)
61
66
 
62
- raise UnsupportedBase.new(base, DEFAULT_PREFIXES)
67
+ raise Plurimath::Errors::UnsupportedBase.new(base, DEFAULT_PREFIXES)
63
68
  end
64
69
  end
65
70
  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!
@@ -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?
@@ -44,7 +44,11 @@ module Plurimath
44
44
  def render_engineering(source)
45
45
  parts = notation_parts(source)
46
46
  parts = engineering_notation_parts(parts) unless source.decimal.zero?
47
- parts[0] = localize_parts(source, parts[0], precision: engineering_precision(source))
47
+ parts[0] = localize_parts(
48
+ source,
49
+ parts[0],
50
+ precision: engineering_precision(source, parts[0]),
51
+ )
48
52
  parts.join(" #{options.times} 10^")
49
53
  end
50
54
 
@@ -66,7 +70,10 @@ module Plurimath
66
70
  end
67
71
 
68
72
  def notation_parts(source)
69
- return [source.to_parts(base: options.base), 0] if source.decimal.zero?
73
+ if source.decimal.zero?
74
+ return [source.to_parts(base: options.base),
75
+ 0]
76
+ end
70
77
 
71
78
  parts = source.to_parts(base: Base::DEFAULT_BASE)
72
79
  digits, exponent = significant_digits_and_exponent(parts)
@@ -87,7 +94,9 @@ module Plurimath
87
94
  exponent -= index
88
95
  digits = "#{coefficient.integer_digits}#{coefficient.fraction_digits}"
89
96
  integer_length = index + 1
90
- integer_digits = digits[0...integer_length].to_s.ljust(integer_length, "0")
97
+ integer_digits = digits[0...integer_length].to_s.ljust(
98
+ integer_length, "0"
99
+ )
91
100
 
92
101
  [
93
102
  Parts.new(
@@ -100,10 +109,30 @@ module Plurimath
100
109
  ]
101
110
  end
102
111
 
103
- def engineering_precision(source)
104
- return precision if precision.positive?
112
+ # The inferred budget covers the source's significant digits; the
113
+ # engineering shift moves one to three of them into the integer part
114
+ # (per exponent mod 3), so the fraction budget must subtract the
115
+ # shifted integer width. Only a positive explicit precision is taken
116
+ # as a literal fraction width; precision: 0 falls through to inference.
117
+ def engineering_precision(source, coefficient)
118
+ # precision: 0 is intentionally not literal here; it infers.
119
+ return precision if options.explicit_precision? && precision.positive?
120
+ # Zero sources carry their stated fraction width; an explicit
121
+ # precision: 0 still infers (consistent with non-zero engineering).
122
+ return source.notation_precision if source.decimal.zero?
123
+
124
+ integer_length = coefficient.integer_digits.length
125
+ budget = [options.significant, options.digit_count].max
126
+ unless budget.positive?
127
+ return [source.significant_digit_count - integer_length,
128
+ 0].max
129
+ end
105
130
 
106
- [source.significant_digit_count - 1, 0].max
131
+ fraction_budget = [budget - integer_length, 0].max
132
+ # Leave one digit for Significant's rounding pass when the source
133
+ # carries more digits than the requested budget.
134
+ fraction_budget += 1 if source.significant_digit_count > budget
135
+ fraction_budget
107
136
  end
108
137
 
109
138
  def significant_digits_and_exponent(parts)
@@ -26,15 +26,18 @@ module Plurimath
26
26
  render_precision = precision || options.precision || source_precision
27
27
  parts = source.to_parts(
28
28
  base: options.base,
29
- precision: render_precision,
29
+ precision: decimal_precision_for(render_precision),
30
30
  )
31
31
  format_parts(parts, precision: render_precision)
32
32
  end
33
33
 
34
34
  def format_parts(parts, precision:)
35
- parts = parts.with_digits(
36
- fraction_digits: parts.fraction_digits[0...precision.to_i].to_s,
37
- )
35
+ decimal_precision = decimal_precision_for(precision)
36
+ unless decimal_precision.nil?
37
+ parts = parts.with_digits(
38
+ fraction_digits: parts.fraction_digits[0...decimal_precision.to_i].to_s,
39
+ )
40
+ end
38
41
  parts = renderable_parts(parts, precision: precision)
39
42
 
40
43
  parts = significant_format.apply_parts(parts) if significant_format.active?
@@ -60,6 +63,15 @@ module Plurimath
60
63
 
61
64
  source.fraction_digits.length
62
65
  end
66
+
67
+ # Precision budgets target-base digits, so the decimal fraction must
68
+ # stay intact until Fraction#change_base generates them; truncating
69
+ # decimal digits first loses value (0.07 in hex became 0x0.0).
70
+ def decimal_precision_for(precision)
71
+ return precision if options.base == Base::DEFAULT_BASE
72
+
73
+ nil
74
+ end
63
75
  end
64
76
  end
65
77
  end