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
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plurimath
4
+ module Deprecation
5
+ BEHAVIORS = %i[collect raise silence].freeze
6
+ DEFAULT_BEHAVIOR = :collect
7
+
8
+ class << self
9
+ def warn(feature:, message: nil, replacement: nil, since: nil, remove_in: nil)
10
+ feature = validate_feature(feature)
11
+ return if behavior == :collect && emitted_features[feature]
12
+
13
+ notice = build_notice(
14
+ feature: feature,
15
+ message: message,
16
+ replacement: replacement,
17
+ since: since,
18
+ remove_in: remove_in,
19
+ )
20
+
21
+ case behavior
22
+ when :silence
23
+ nil
24
+ when :raise
25
+ raise notice
26
+ when :collect
27
+ emitted_features[feature] = notice
28
+ end
29
+ end
30
+
31
+ def behavior
32
+ @behavior ||= DEFAULT_BEHAVIOR
33
+ end
34
+
35
+ def behavior=(behavior)
36
+ @behavior = validate_behavior(behavior)
37
+ end
38
+
39
+ def notices
40
+ emitted_features.values
41
+ end
42
+
43
+ def clear!
44
+ @emitted_features = {}
45
+ end
46
+
47
+ private
48
+
49
+ def build_notice(feature:, message:, replacement:, since:, remove_in:)
50
+ DeprecationError.new(
51
+ feature: feature,
52
+ message: message,
53
+ replacement: replacement,
54
+ since: since,
55
+ remove_in: remove_in,
56
+ )
57
+ end
58
+
59
+ def emitted_features
60
+ @emitted_features ||= {}
61
+ end
62
+
63
+ def validate_behavior(behavior)
64
+ return behavior if BEHAVIORS.include?(behavior)
65
+
66
+ raise ConfigurationError.new(
67
+ :unsupported_deprecation_behavior,
68
+ value: behavior,
69
+ supported: BEHAVIORS,
70
+ )
71
+ end
72
+
73
+ def validate_feature(feature)
74
+ feature = feature.to_s
75
+ return feature unless feature.empty?
76
+
77
+ raise ConfigurationError.new(:missing_deprecation_feature)
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plurimath
4
+ class ConfigurationError < Error
5
+ def initialize(type, value: nil, supported: nil)
6
+ @type = type
7
+ @value = value
8
+ @supported = supported
9
+ super(message)
10
+ end
11
+
12
+ def message
13
+ case @type
14
+ when :unsupported_deprecation_behavior
15
+ "unsupported deprecation behavior: #{@value.inspect}; " \
16
+ "expected one of #{@supported.inspect}"
17
+ when :missing_deprecation_feature
18
+ "deprecation feature must be provided"
19
+ when :conflicting_formatter_options
20
+ "formatter options cannot be used together: choose either " \
21
+ ":padding_digits or :padding_group_digits"
22
+ else
23
+ "invalid Plurimath configuration"
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plurimath
4
+ class DeprecationError < Error
5
+ SEVERITY = :warning
6
+
7
+ attr_reader :feature, :replacement, :since, :remove_in
8
+
9
+ def initialize(feature:, message: nil, replacement: nil, since: nil, remove_in: nil)
10
+ @feature = feature
11
+ @replacement = replacement
12
+ @since = since
13
+ @remove_in = remove_in
14
+ @user_message = message
15
+ super(to_s)
16
+ end
17
+
18
+ def severity
19
+ SEVERITY
20
+ end
21
+
22
+ def to_s
23
+ deprecation = "[plurimath][DEPRECATION] #{feature} is deprecated"
24
+ deprecation = "#{deprecation} since #{since}" if since
25
+ deprecation = "#{deprecation} and will be removed in #{remove_in}" if remove_in
26
+
27
+ parts = [deprecation]
28
+ parts << "Use #{replacement} instead" if replacement
29
+ parts << @user_message if @user_message
30
+ "#{parts.join('. ')}."
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plurimath
4
+ class Error < StandardError
5
+ end
6
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Plurimath
4
4
  module Formatter
5
- class UnsupportedBase < StandardError
5
+ class UnsupportedBase < Plurimath::Error
6
6
  def initialize(base, supported_bases)
7
7
  @base = base
8
8
  @supported = supported_bases.keys.map do |key|
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plurimath
4
+ module Formatter
5
+ class UnsupportedLocale < Plurimath::Error
6
+ def initialize(locale, supported_locales)
7
+ @locale = locale
8
+ @supported = supported_locales.map(&:inspect).join(", ")
9
+ super(message)
10
+ end
11
+
12
+ def message
13
+ "[plurimath] Unsupported locale #{@locale.inspect}. " \
14
+ "Supported locales are: #{@supported}."
15
+ end
16
+ end
17
+ end
18
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Plurimath
4
4
  class Omml
5
- class UnsupportedNodeError < StandardError
5
+ class UnsupportedNodeError < Plurimath::Error
6
6
  def initialize(node)
7
7
  @node = node
8
8
  super(message)
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Plurimath
4
4
  module Math
5
- class ParseError < StandardError
5
+ class ParseError < Plurimath::Error
6
6
  def initialize(text, type)
7
7
  @text = text
8
8
  @type = type.to_sym
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plurimath
4
+ module Math
5
+ class ParseOptionError < Plurimath::Error
6
+ def self.unknown_options(options, supported_options:)
7
+ new(
8
+ "unknown parse #{option_label(options)}: #{format_options(options)}; " \
9
+ "supported parse options are #{format_options(supported_options)}",
10
+ )
11
+ end
12
+
13
+ def self.unsupported_options(type, options, supported_types:)
14
+ new(
15
+ "parse #{option_label(options)} #{format_options(options)} " \
16
+ "#{be_verb(options)} not supported for #{type.inspect}; " \
17
+ "supported input types are #{format_options(supported_types)}",
18
+ )
19
+ end
20
+
21
+ def self.option_label(options)
22
+ options.one? ? "option" : "options"
23
+ end
24
+
25
+ def self.be_verb(options)
26
+ options.one? ? "is" : "are"
27
+ end
28
+
29
+ def self.format_options(options)
30
+ options.map(&:inspect).join(", ")
31
+ end
32
+ end
33
+ end
34
+ end
@@ -3,22 +3,25 @@
3
3
  module Plurimath
4
4
  module Formatter
5
5
  module Numbers
6
+ # Shared base for formatter helpers that need resolved options, target
7
+ # base state, and common digit operations.
6
8
  class Base
9
+ HEX_DIGITS = "abcdef"
7
10
  HEX_ALPHANUMERIC = %w[0 1 2 3 4 5 6 7 8 9 a b c d e f].freeze
8
11
  DEFAULT_BASE = 10
9
12
  DIGIT_VALUE = HEX_ALPHANUMERIC.each_with_index.to_h
10
13
 
11
- attr_accessor :base, :symbols
14
+ attr_accessor :base, :options
12
15
 
13
- def initialize(symbols = {})
14
- @symbols = symbols
15
- @base = symbols[:base] || DEFAULT_BASE
16
+ def initialize(options)
17
+ @options = options
18
+ @base = @options.base
16
19
  end
17
20
 
18
21
  protected
19
22
 
20
23
  def threshold
21
- @threshold ||= base.div(2)
24
+ digit_sequence.threshold
22
25
  end
23
26
 
24
27
  def base_default?
@@ -26,10 +29,17 @@ module Plurimath
26
29
  end
27
30
 
28
31
  def next_mapping_char(char)
29
- current_idx = DIGIT_VALUE[char]
30
- return nil unless current_idx
32
+ digit_sequence.next_digit(char)
33
+ end
34
+
35
+ def capitalize_hex_digits(string)
36
+ return string unless base == 16 && options.hex_capital == :numbers_only
37
+
38
+ string.tr(HEX_DIGITS, HEX_DIGITS.upcase)
39
+ end
31
40
 
32
- HEX_ALPHANUMERIC[current_idx + 1]
41
+ def digit_sequence
42
+ @digit_sequence ||= DigitSequence.new(base: base)
33
43
  end
34
44
  end
35
45
  end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plurimath
4
+ module Formatter
5
+ module Numbers
6
+ # Applies base prefix/postfix notation after numeric digits have already
7
+ # been rendered.
8
+ class BaseNotation
9
+ DEFAULT_PREFIXES = {
10
+ 2 => "0b",
11
+ 8 => "0o",
12
+ 10 => "",
13
+ 16 => "0x",
14
+ }.freeze
15
+
16
+ attr_reader :base
17
+
18
+ def initialize(options)
19
+ @options = options
20
+ @base = @options.base
21
+ validate_base!
22
+ end
23
+
24
+ def apply(string)
25
+ rendered = upcase_hex? ? string.tr(Base::HEX_DIGITS, Base::HEX_DIGITS.upcase) : string
26
+ return rendered if default?
27
+
28
+ "#{base_prefix}#{rendered}#{base_postfix}"
29
+ end
30
+
31
+ def default?
32
+ base == Base::DEFAULT_BASE
33
+ end
34
+
35
+ def self.supported?(base)
36
+ DEFAULT_PREFIXES.key?(base)
37
+ end
38
+
39
+ private
40
+
41
+ attr_reader :options
42
+
43
+ def base_prefix
44
+ return options.base_prefix if options.base_prefix?
45
+ # A postfix without an explicit prefix opts out of the default prefix.
46
+ return "" if options.base_postfix?
47
+
48
+ DEFAULT_PREFIXES[base]
49
+ end
50
+
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)
61
+
62
+ raise UnsupportedBase.new(base, DEFAULT_PREFIXES)
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plurimath
4
+ module Formatter
5
+ module Numbers
6
+ # Shared base-digit helper for counting, rounding thresholds, next-digit
7
+ # lookup, and carry propagation.
8
+ class DigitSequence
9
+ attr_reader :base
10
+
11
+ ZERO = "0"
12
+
13
+ def initialize(base:)
14
+ @base = base
15
+ end
16
+
17
+ def digit?(char)
18
+ Base::DIGIT_VALUE.key?(char)
19
+ end
20
+
21
+ def significant?(char)
22
+ Base::DIGIT_VALUE[char]&.positive?
23
+ end
24
+
25
+ def digit_count(chars, stop_at: nil)
26
+ count = 0
27
+ each_countable_digit(chars, stop_at: stop_at) { count += 1 }
28
+ count
29
+ end
30
+
31
+ def significant_digit_count(chars)
32
+ start_counting = false
33
+ count = 0
34
+
35
+ each_countable_digit(chars) do |char|
36
+ start_counting = true if significant?(char)
37
+ count += 1 if start_counting
38
+ end
39
+
40
+ count
41
+ end
42
+
43
+ def max_digit?(char)
44
+ Base::DIGIT_VALUE[char] == base.pred
45
+ end
46
+
47
+ def next_digit(char)
48
+ current_index = Base::DIGIT_VALUE[char]
49
+ return unless current_index
50
+
51
+ Base::HEX_ALPHANUMERIC[current_index + 1]
52
+ end
53
+
54
+ def round_up?(char)
55
+ value = Base::DIGIT_VALUE[char]
56
+ !!(value && value >= threshold)
57
+ end
58
+
59
+ def threshold
60
+ @threshold ||= base.div(2)
61
+ end
62
+
63
+ def increment_reversed(digits, carry: 1, skip: [], overflow: ZERO)
64
+ digits = digits.dup
65
+ return [digits, carry] unless carry.positive?
66
+
67
+ digits.each_with_index do |digit, index|
68
+ next if skip.include?(digit)
69
+ next unless digit?(digit)
70
+
71
+ if max_digit?(digit)
72
+ digits[index] = overflow
73
+ else
74
+ digits[index] = next_digit(digit)
75
+ carry = 0
76
+ break
77
+ end
78
+ end
79
+
80
+ [digits, carry]
81
+ end
82
+
83
+ private
84
+
85
+ def each_countable_digit(chars, stop_at: nil)
86
+ chars.each do |char|
87
+ break if stop_at && char == stop_at
88
+ next unless digit?(char)
89
+
90
+ yield(char)
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plurimath
4
+ module Formatter
5
+ module Numbers
6
+ # Per-render view of merged formatter symbols and resolved precision.
7
+ class FormatOptions
8
+ DEFAULT_EXPONENT_SEPARATOR = :e
9
+ DEFAULT_DECIMAL = "."
10
+ DEFAULT_FRACTION_PRECISION = 3
11
+ DEFAULT_GROUP = ","
12
+ DEFAULT_GROUP_DIGITS = 3
13
+ DEFAULT_PADDING = "0"
14
+ DEFAULT_TIMES = "\u{d7}"
15
+
16
+ attr_reader :exponent_separator, :exponent_sign, :notation, :symbols,
17
+ :precision, :times
18
+
19
+ def initialize(
20
+ source = nil,
21
+ symbols: {},
22
+ precision: nil,
23
+ precision_resolver: nil
24
+ )
25
+ @symbols = symbols.dup
26
+ @notation = symbol_option(:notation)
27
+ @exponent_separator = symbol_option(:e) || DEFAULT_EXPONENT_SEPARATOR
28
+ @times = symbol_option(:times) || DEFAULT_TIMES
29
+ @precision = resolve_precision(source, precision, precision_resolver)
30
+ @exponent_sign = symbol_option(:exponent_sign)
31
+ validate_padding_options!
32
+ end
33
+
34
+ def base
35
+ symbols[:base] || Base::DEFAULT_BASE
36
+ end
37
+
38
+ def base_prefix
39
+ symbols[:base_prefix].to_s
40
+ end
41
+
42
+ def base_prefix?
43
+ symbols.key?(:base_prefix)
44
+ end
45
+
46
+ def base_postfix
47
+ symbols[:base_postfix]
48
+ end
49
+
50
+ def base_postfix?
51
+ symbols.key?(:base_postfix)
52
+ end
53
+
54
+ def decimal
55
+ symbols.fetch(:decimal, DEFAULT_DECIMAL)
56
+ end
57
+
58
+ def digit_count
59
+ symbols[:digit_count].to_i
60
+ end
61
+
62
+ def fraction_group
63
+ symbols[:fraction_group].to_s
64
+ end
65
+
66
+ def fraction_group_digits
67
+ symbols[:fraction_group_digits]
68
+ end
69
+
70
+ def group
71
+ symbols[:group] || DEFAULT_GROUP
72
+ end
73
+
74
+ def group_digits
75
+ symbols[:group_digits] || DEFAULT_GROUP_DIGITS
76
+ end
77
+
78
+ def hex_capital
79
+ symbols[:hex_capital]
80
+ end
81
+
82
+ def number_sign
83
+ symbols[:number_sign]
84
+ end
85
+
86
+ def padding
87
+ value = symbols.fetch(:padding, DEFAULT_PADDING).to_s
88
+ value.empty? ? DEFAULT_PADDING : value[0]
89
+ end
90
+
91
+ def padding_digits
92
+ symbols[:padding_digits].to_i
93
+ end
94
+
95
+ def padding_group_digits
96
+ symbols[:padding_group_digits].to_i
97
+ end
98
+
99
+ def notation_supported?
100
+ NotationRenderer.supported?(notation)
101
+ end
102
+
103
+ def significant
104
+ symbols[:significant].to_i
105
+ end
106
+
107
+ def to_h
108
+ symbols.dup
109
+ end
110
+
111
+ private
112
+
113
+ def resolve_precision(source, precision, precision_resolver)
114
+ effective_precision = precision || symbols[:precision]
115
+ return effective_precision unless precision_resolver
116
+
117
+ precision_resolver.resolve(
118
+ source,
119
+ precision: effective_precision,
120
+ base: base,
121
+ significant: significant,
122
+ notation_supported: notation_supported?,
123
+ )
124
+ end
125
+
126
+ def symbol_option(key)
127
+ symbols[key]&.to_sym
128
+ end
129
+
130
+ def validate_padding_options!
131
+ return unless symbols.key?(:padding_digits) && symbols.key?(:padding_group_digits)
132
+
133
+ raise Plurimath::ConfigurationError.new(
134
+ :conflicting_formatter_options,
135
+ supported: %i[padding_digits padding_group_digits],
136
+ )
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end