plurimath 0.11.11 → 0.11.13

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 (170) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/dependent-tests.yml +7 -0
  3. data/.github/workflows/shared-testsuite.yml +24 -0
  4. data/.gitmodules +3 -0
  5. data/.rspec-opal +3 -0
  6. data/Gemfile +18 -0
  7. data/README.adoc +297 -3
  8. data/docs/catalog.adoc +73 -0
  9. data/docs/uncertainty-asciimath-tree.md +92 -0
  10. data/docs/uncertainty.adoc +522 -0
  11. data/lib/plurimath/asciimath/parse.rb +43 -226
  12. data/lib/plurimath/asciimath/parser.rb +58 -1
  13. data/lib/plurimath/asciimath/slice_adapter.rb +138 -0
  14. data/lib/plurimath/asciimath/transform.rb +218 -0
  15. data/lib/plurimath/asciimath.rb +1 -0
  16. data/lib/plurimath/catalog/entry.rb +109 -0
  17. data/lib/plurimath/catalog/validation.rb +109 -0
  18. data/lib/plurimath/catalog.rb +96 -0
  19. data/lib/plurimath/cli/convert.rb +40 -0
  20. data/lib/plurimath/cli/helpers.rb +49 -0
  21. data/lib/plurimath/cli/render.rb +64 -0
  22. data/lib/plurimath/cli.rb +55 -57
  23. data/lib/plurimath/documentation.rb +99 -0
  24. data/lib/plurimath/errors/ambiguous_decimal_error.rb +19 -0
  25. data/lib/plurimath/errors/invalid_uncertainty.rb +18 -0
  26. data/lib/plurimath/errors/missing_dependency_error.rb +23 -0
  27. data/lib/plurimath/errors/rendering_failed.rb +17 -0
  28. data/lib/plurimath/errors/rendering_unavailable.rb +25 -0
  29. data/lib/plurimath/errors/unsupported_render_format.rb +18 -0
  30. data/lib/plurimath/errors/unsupported_semantic_tree_error.rb +12 -0
  31. data/lib/plurimath/errors.rb +5 -0
  32. data/lib/plurimath/math/evaluation/evaluator.rb +48 -0
  33. data/lib/plurimath/math/evaluation/expression_parser.rb +29 -20
  34. data/lib/plurimath/math/formula/class_tree.rb +92 -0
  35. data/lib/plurimath/math/formula.rb +43 -2
  36. data/lib/plurimath/math/function/abs.rb +6 -0
  37. data/lib/plurimath/math/function/arccos.rb +6 -0
  38. data/lib/plurimath/math/function/arcsin.rb +6 -0
  39. data/lib/plurimath/math/function/arctan.rb +6 -0
  40. data/lib/plurimath/math/function/arg.rb +20 -0
  41. data/lib/plurimath/math/function/bar.rb +6 -0
  42. data/lib/plurimath/math/function/base.rb +6 -0
  43. data/lib/plurimath/math/function/binary_function.rb +4 -0
  44. data/lib/plurimath/math/function/cancel.rb +6 -0
  45. data/lib/plurimath/math/function/ceil.rb +6 -0
  46. data/lib/plurimath/math/function/color.rb +12 -0
  47. data/lib/plurimath/math/function/cos.rb +6 -0
  48. data/lib/plurimath/math/function/cosh.rb +6 -0
  49. data/lib/plurimath/math/function/cot.rb +6 -0
  50. data/lib/plurimath/math/function/coth.rb +6 -0
  51. data/lib/plurimath/math/function/csc.rb +6 -0
  52. data/lib/plurimath/math/function/csch.rb +6 -0
  53. data/lib/plurimath/math/function/ddot.rb +6 -0
  54. data/lib/plurimath/math/function/deg.rb +6 -0
  55. data/lib/plurimath/math/function/det.rb +6 -0
  56. data/lib/plurimath/math/function/dim.rb +6 -0
  57. data/lib/plurimath/math/function/dot.rb +6 -0
  58. data/lib/plurimath/math/function/exp.rb +6 -0
  59. data/lib/plurimath/math/function/fenced.rb +6 -0
  60. data/lib/plurimath/math/function/floor.rb +6 -0
  61. data/lib/plurimath/math/function/font_style/bold-fraktur.rb +6 -0
  62. data/lib/plurimath/math/function/font_style/bold-italic.rb +6 -0
  63. data/lib/plurimath/math/function/font_style/bold-sans-serif.rb +6 -0
  64. data/lib/plurimath/math/function/font_style/bold-script.rb +6 -0
  65. data/lib/plurimath/math/function/font_style/bold.rb +6 -0
  66. data/lib/plurimath/math/function/font_style/double_struck.rb +6 -0
  67. data/lib/plurimath/math/function/font_style/fraktur.rb +6 -0
  68. data/lib/plurimath/math/function/font_style/italic.rb +6 -0
  69. data/lib/plurimath/math/function/font_style/monospace.rb +6 -0
  70. data/lib/plurimath/math/function/font_style/normal.rb +6 -0
  71. data/lib/plurimath/math/function/font_style/sans-serif-bold-italic.rb +6 -0
  72. data/lib/plurimath/math/function/font_style/sans-serif-italic.rb +6 -0
  73. data/lib/plurimath/math/function/font_style/sans-serif.rb +6 -0
  74. data/lib/plurimath/math/function/font_style/script.rb +6 -0
  75. data/lib/plurimath/math/function/font_style.rb +4 -0
  76. data/lib/plurimath/math/function/frac.rb +6 -0
  77. data/lib/plurimath/math/function/gcd.rb +6 -0
  78. data/lib/plurimath/math/function/glb.rb +12 -0
  79. data/lib/plurimath/math/function/hat.rb +6 -0
  80. data/lib/plurimath/math/function/hom.rb +6 -0
  81. data/lib/plurimath/math/function/inf.rb +6 -0
  82. data/lib/plurimath/math/function/int.rb +7 -1
  83. data/lib/plurimath/math/function/intent.rb +35 -0
  84. data/lib/plurimath/math/function/ker.rb +6 -0
  85. data/lib/plurimath/math/function/lcm.rb +12 -0
  86. data/lib/plurimath/math/function/lg.rb +6 -0
  87. data/lib/plurimath/math/function/lim.rb +6 -0
  88. data/lib/plurimath/math/function/liminf.rb +6 -0
  89. data/lib/plurimath/math/function/limits.rb +6 -0
  90. data/lib/plurimath/math/function/limsup.rb +6 -0
  91. data/lib/plurimath/math/function/ln.rb +6 -0
  92. data/lib/plurimath/math/function/log.rb +6 -0
  93. data/lib/plurimath/math/function/longdiv.rb +6 -0
  94. data/lib/plurimath/math/function/lub.rb +6 -0
  95. data/lib/plurimath/math/function/max.rb +6 -0
  96. data/lib/plurimath/math/function/mbox.rb +6 -0
  97. data/lib/plurimath/math/function/menclose.rb +19 -0
  98. data/lib/plurimath/math/function/merror.rb +22 -0
  99. data/lib/plurimath/math/function/min.rb +6 -0
  100. data/lib/plurimath/math/function/mlabeledtr.rb +23 -0
  101. data/lib/plurimath/math/function/mod.rb +6 -0
  102. data/lib/plurimath/math/function/msgroup.rb +20 -0
  103. data/lib/plurimath/math/function/multiscript.rb +6 -0
  104. data/lib/plurimath/math/function/nary.rb +11 -0
  105. data/lib/plurimath/math/function/norm.rb +6 -0
  106. data/lib/plurimath/math/function/obrace.rb +6 -0
  107. data/lib/plurimath/math/function/oint.rb +7 -1
  108. data/lib/plurimath/math/function/over.rb +6 -0
  109. data/lib/plurimath/math/function/overleftrightarrow.rb +6 -0
  110. data/lib/plurimath/math/function/overset.rb +6 -0
  111. data/lib/plurimath/math/function/phantom.rb +6 -0
  112. data/lib/plurimath/math/function/power.rb +6 -0
  113. data/lib/plurimath/math/function/power_base.rb +6 -0
  114. data/lib/plurimath/math/function/prod.rb +7 -1
  115. data/lib/plurimath/math/function/right.rb +1 -1
  116. data/lib/plurimath/math/function/root.rb +6 -0
  117. data/lib/plurimath/math/function/rule.rb +20 -0
  118. data/lib/plurimath/math/function/scarries.rb +20 -0
  119. data/lib/plurimath/math/function/sec.rb +6 -0
  120. data/lib/plurimath/math/function/sech.rb +6 -0
  121. data/lib/plurimath/math/function/semantics.rb +20 -0
  122. data/lib/plurimath/math/function/sin.rb +6 -0
  123. data/lib/plurimath/math/function/sinh.rb +6 -0
  124. data/lib/plurimath/math/function/sqrt.rb +6 -0
  125. data/lib/plurimath/math/function/stackrel.rb +6 -0
  126. data/lib/plurimath/math/function/substack.rb +6 -0
  127. data/lib/plurimath/math/function/sum.rb +7 -1
  128. data/lib/plurimath/math/function/sup.rb +6 -0
  129. data/lib/plurimath/math/function/table/align.rb +6 -0
  130. data/lib/plurimath/math/function/table/array.rb +6 -0
  131. data/lib/plurimath/math/function/table/bmatrix.rb +6 -0
  132. data/lib/plurimath/math/function/table/cases.rb +6 -0
  133. data/lib/plurimath/math/function/table/eqarray.rb +6 -0
  134. data/lib/plurimath/math/function/table/matrix.rb +6 -0
  135. data/lib/plurimath/math/function/table/multline.rb +6 -0
  136. data/lib/plurimath/math/function/table/pmatrix.rb +6 -0
  137. data/lib/plurimath/math/function/table/split.rb +6 -0
  138. data/lib/plurimath/math/function/table/vmatrix.rb +6 -0
  139. data/lib/plurimath/math/function/table.rb +10 -0
  140. data/lib/plurimath/math/function/tan.rb +6 -0
  141. data/lib/plurimath/math/function/tanh.rb +6 -0
  142. data/lib/plurimath/math/function/ternary_function.rb +4 -0
  143. data/lib/plurimath/math/function/text.rb +6 -0
  144. data/lib/plurimath/math/function/tilde.rb +6 -0
  145. data/lib/plurimath/math/function/ubrace.rb +6 -0
  146. data/lib/plurimath/math/function/ul.rb +6 -0
  147. data/lib/plurimath/math/function/unary_function.rb +4 -0
  148. data/lib/plurimath/math/function/uncertainty.rb +655 -0
  149. data/lib/plurimath/math/function/underover.rb +6 -0
  150. data/lib/plurimath/math/function/underset.rb +6 -0
  151. data/lib/plurimath/math/function/vec.rb +6 -0
  152. data/lib/plurimath/math/function.rb +1 -0
  153. data/lib/plurimath/math/renderer.rb +95 -0
  154. data/lib/plurimath/math/semantic_tree.rb +331 -0
  155. data/lib/plurimath/math/symbols/symbol.rb +12 -5
  156. data/lib/plurimath/math.rb +26 -0
  157. data/lib/plurimath/mathml/translator.rb +32 -0
  158. data/lib/plurimath/mathml/uncertainty_marker.rb +118 -0
  159. data/lib/plurimath/omml/translator.rb +68 -0
  160. data/lib/plurimath/omml/utility.rb +9 -1
  161. data/lib/plurimath/symbol_documentation.rb +57 -0
  162. data/lib/plurimath/unicode_math/parsing_rules/common_rules.rb +2 -1
  163. data/lib/plurimath/unicode_math/parsing_rules/masked.rb +3 -0
  164. data/lib/plurimath/unicode_math/transform.rb +64 -3
  165. data/lib/plurimath/unicode_math/utility.rb +38 -0
  166. data/lib/plurimath/version.rb +1 -1
  167. data/lib/plurimath.rb +3 -0
  168. data/plurimath.gemspec +3 -1
  169. data/scripts/replay_asciimath_corpus.rb +611 -0
  170. metadata +30 -4
@@ -105,6 +105,7 @@ module Plurimath
105
105
  autoload :Underbrace, "#{__dir__}/function/ubrace"
106
106
  autoload :Underline, "#{__dir__}/function/ul"
107
107
  autoload :UnaryFunction, "#{__dir__}/function/unary_function"
108
+ autoload :Uncertainty, "#{__dir__}/function/uncertainty"
108
109
  autoload :Underover, "#{__dir__}/function/underover"
109
110
  autoload :Underset, "#{__dir__}/function/underset"
110
111
  autoload :Unitsml, "#{__dir__}/function/unitsml"
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plurimath
4
+ module Math
5
+ # Internal bridge to the optional `lasem` gem, which renders MathML to
6
+ # SVG/PNG/PDF/PostScript through a native extension.
7
+ #
8
+ # This is NOT part of the public API: reach rendering via Plurimath::Math.render
9
+ # or Plurimath::Math::Formula#render. Renderer is the single place `lasem`
10
+ # (native, Opal-incompatible) is required, so the rest of the library stays
11
+ # decoupled from it. Opal excludes the guarded native require when compiling
12
+ # this autoloaded bridge, and available? is always false under Opal.
13
+ #
14
+ # Not thread-safe: the underlying Lasem native render keeps static parser
15
+ # state and the +@load_error+ diagnostic is shared module state, so callers
16
+ # should not render concurrently.
17
+ module Renderer
18
+ # Output image formats accepted by lasem.
19
+ OUTPUT_FORMATS = %i[svg png pdf ps].freeze
20
+
21
+ # Keyword options forwarded to Formula#to_mathml (the serialization step).
22
+ MATHML_OPTIONS = %i[
23
+ intent formatter unitsml split_on_linebreak display_style
24
+ unary_function_spacing
25
+ ].freeze
26
+
27
+ # Geometry options forwarded straight to Lasem.render. The names match
28
+ # lasem's own kwargs; nil values are dropped so lasem applies its defaults.
29
+ LASEM_OPTIONS = %i[ppi zoom width height offset_x offset_y].freeze
30
+
31
+ module_function
32
+
33
+ # Render a MathML document to a binary image string. Raises a
34
+ # Plurimath::Errors rendering error (UnsupportedRenderFormat,
35
+ # RenderingUnavailable, or RenderingFailed) on any problem.
36
+ def render(mathml, format:, **opts)
37
+ format = normalize_format(format)
38
+ ensure_available!
39
+ render_with_backend(mathml, format, opts)
40
+ end
41
+
42
+ # Only the backend call is wrapped here, so the UnsupportedRenderFormat /
43
+ # RenderingUnavailable errors raised above are not re-wrapped.
44
+ def render_with_backend(mathml, format, opts)
45
+ Lasem.render(
46
+ mathml,
47
+ input: :mathml,
48
+ output: format,
49
+ **opts.slice(*LASEM_OPTIONS).compact,
50
+ )
51
+ rescue StandardError => e
52
+ # Catches Lasem::RenderError/DependencyError AND Lasem::OptionError, which
53
+ # is an ArgumentError living OUTSIDE Lasem::Error (lasem-ruby errmodel-1),
54
+ # so we deliberately rescue StandardError rather than Lasem::Error.
55
+ raise Errors::RenderingFailed.new(format, e)
56
+ end
57
+ private_class_method :render_with_backend
58
+
59
+ # True only when this is a non-Opal runtime, the `lasem` gem loads, and its
60
+ # native extension reports itself available. Never raises.
61
+ def available?
62
+ return false if RUBY_ENGINE == "opal"
63
+
64
+ @load_error = nil
65
+ load_lasem && Lasem.native_available?
66
+ rescue StandardError => e
67
+ @load_error = e
68
+ false
69
+ end
70
+
71
+ def normalize_format(format)
72
+ symbol = format.to_s.downcase.to_sym
73
+ return symbol if OUTPUT_FORMATS.include?(symbol)
74
+
75
+ raise Errors::UnsupportedRenderFormat.new(format, OUTPUT_FORMATS)
76
+ end
77
+
78
+ def ensure_available!
79
+ return if available?
80
+
81
+ raise Errors::RenderingUnavailable.new(@load_error)
82
+ end
83
+
84
+ # Lazily require the optional gem. Only ever reached on the non-Opal path.
85
+ # Returns true on success, false on LoadError (recorded for diagnostics).
86
+ def load_lasem
87
+ require "lasem" unless RUBY_ENGINE == "opal"
88
+ true
89
+ rescue LoadError => e
90
+ @load_error = e
91
+ false
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,331 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plurimath
4
+ module Math
5
+ # Builds a transient display view using the evaluator's precedence parser.
6
+ # It never evaluates operands or replaces the Formula's stored model.
7
+ class SemanticTree
8
+ View = Struct.new(:label, :children, :source)
9
+ private_constant :View
10
+
11
+ LANGUAGES = %i[omml latex mathml asciimath unicodemath].freeze
12
+ private_constant :LANGUAGES
13
+
14
+ RELATIONS = %w[Equal Less Greater Lt Gt Le Ge Leq Geq Ne Neq].freeze
15
+ APPLICATIONS = %w[
16
+ Sin Cos Tan Cot Sec Csc Sinh Cosh Tanh Coth Sech Csch
17
+ Arcsin Arccos Arctan Arccot Arcsec Arccsc Arcsinh Arccosh Arctanh
18
+ Ln Exp Abs Sgn Det Min Max Gcd Lcm
19
+ ].freeze
20
+ private_constant :RELATIONS, :APPLICATIONS
21
+
22
+ def initialize(language, options)
23
+ unsupported("label language #{language.inspect}") unless LANGUAGES.include?(language)
24
+ @language = language
25
+ @options = options
26
+ end
27
+
28
+ def render(formula)
29
+ @label_context = formula.cloned_objects
30
+ root = evaluate_node(formula)
31
+ (["|_ Semantic tree", " |_ expression #{label(root.source)}"] + lines(root, 2)).join("\n")
32
+ end
33
+
34
+ # Normalize only the symbolic backend's input. Some source parsers store
35
+ # Unicode minus and function application as generic Symbol instances.
36
+ def normalize_tokens(tokens)
37
+ normalized = []
38
+ index = 0
39
+ while index < tokens.length
40
+ node = tokens[index]
41
+ if node.instance_of?(Symbols::Symbol) && ["−", "&#x2212;"].include?(node.value)
42
+ node = Symbols::Minus.new
43
+ elsif unbound_application?(node)
44
+ offset = application_marker?(tokens[index + 1]) ? 2 : 1
45
+ argument = tokens[index + offset]
46
+ valid_argument!(argument)
47
+ node = bind_application(node, argument)
48
+ index += offset
49
+ end
50
+ normalized << node
51
+ index += 1
52
+ end
53
+ normalized
54
+ end
55
+
56
+ def relation_operator?(node)
57
+ return false unless node.is_a?(Symbols::Symbol)
58
+
59
+ RELATIONS.include?(node.class.name.split("::").last) ||
60
+ (node.instance_of?(Symbols::Symbol) && %w[= < > <= >= != ≤ ≥ ≠].include?(node.value))
61
+ end
62
+
63
+ def relation(operator, left, right)
64
+ view("relation #{label(operator)}", left, right,
65
+ source: sequence(left, operator, right))
66
+ end
67
+
68
+ def add(left, right)
69
+ view("add #{label(Symbols::Plus.new)}", left, right,
70
+ source: sequence(left, Symbols::Plus.new, right))
71
+ end
72
+
73
+ def subtract(left, right)
74
+ view("subtract #{label(Symbols::Minus.new)}", left, right,
75
+ source: sequence(left, Symbols::Minus.new, right))
76
+ end
77
+
78
+ def multiply(left, right, implicit:)
79
+ view(implicit ? "implicit multiply" : "multiply", left, right,
80
+ source: sequence(left, implicit ? nil : Symbols::Times.new, right))
81
+ end
82
+
83
+ def divide(left, right)
84
+ view("divide", left, right,
85
+ source: sequence(left, Symbols::Symbol.new("/"), right))
86
+ end
87
+
88
+ def power(left, right)
89
+ composite("power", Function::Power.new(left.source, right.source),
90
+ role("base", left), role("script", right))
91
+ end
92
+
93
+ def positive(value)
94
+ view("positive", value, source: Formula.new([Symbols::Plus.new, value.source]))
95
+ end
96
+
97
+ def negate(value)
98
+ view("negative", value, source: Formula.new([Symbols::Minus.new, value.source]))
99
+ end
100
+
101
+ def negated_mod(node)
102
+ left = negate(evaluate_node(node.parameter_one))
103
+ right = evaluate_node(node.parameter_two)
104
+ composite("mod", Function::Mod.new(left.source, right.source), left, right)
105
+ end
106
+
107
+ def group(value)
108
+ source = Function::Fenced.new(Symbols::Paren::Lround.new, [value.source], Symbols::Paren::Rround.new)
109
+ composite("group", source, value)
110
+ end
111
+
112
+ def nary_function?(node)
113
+ node.is_a?(Function::Int) || node.is_a?(Function::Sum) || node.is_a?(Function::Prod)
114
+ end
115
+
116
+ def log_argument(node, argument)
117
+ argument = evaluate_node(argument)
118
+ children = []
119
+ children << view("function name #{label(Function::Log.new)}")
120
+ children << role("base", evaluate_node(node.parameter_one)) if node.parameter_one
121
+ children << role("argument", argument)
122
+ function = node.dup
123
+ function.parameter_two = nil
124
+ result = composite("apply", Formula.new([function, argument.source]), *children)
125
+ node.parameter_two ? power(result, evaluate_node(node.parameter_two)) : result
126
+ end
127
+
128
+ def evaluate_node(node)
129
+ case node
130
+ when Formula
131
+ Evaluation::ExpressionParser.new(self, node.value).parse
132
+ when Array
133
+ Evaluation::ExpressionParser.new(self, node).parse
134
+ when Number
135
+ view("number #{label(node)}", source: node)
136
+ when Symbols::Symbol
137
+ symbol(node)
138
+ when Function::Fenced
139
+ fenced(node)
140
+ when Function::Frac
141
+ numerator = evaluate_node(node.parameter_one)
142
+ denominator = evaluate_node(node.parameter_two)
143
+ composite("fraction", with_parameters(node, numerator, denominator),
144
+ role("numerator", numerator), role("denominator", denominator))
145
+ when Function::Sqrt
146
+ radicand = evaluate_node(node.parameter_one)
147
+ composite("square root", with_parameters(node, radicand),
148
+ view('degree "⬚"'), role("radicand", radicand))
149
+ when Function::Root
150
+ index = evaluate_node(node.parameter_one)
151
+ radicand = evaluate_node(node.parameter_two)
152
+ composite("root", with_parameters(node, index, radicand),
153
+ role("index", index), role("radicand", radicand))
154
+ when Function::Power
155
+ power(evaluate_node(node.parameter_one), evaluate_node(node.parameter_two))
156
+ when Function::Base
157
+ base = evaluate_node(node.parameter_one)
158
+ script = evaluate_node(node.parameter_two)
159
+ composite("subscript", with_parameters(node, base, script),
160
+ role("base", base), role("script", script))
161
+ when Function::PowerBase
162
+ base = evaluate_node(node.parameter_one)
163
+ subscript = evaluate_node(node.parameter_two)
164
+ superscript = evaluate_node(node.parameter_three)
165
+ composite("subsup", with_parameters(node, base, subscript, superscript),
166
+ role("base", base), role("subscript", subscript), role("superscript", superscript))
167
+ when Function::Int, Function::Sum, Function::Prod
168
+ nary(node)
169
+ when Function::Mod
170
+ left = evaluate_node(node.parameter_one)
171
+ right = evaluate_node(node.parameter_two)
172
+ composite("mod", with_parameters(node, left, right), left, right)
173
+ when Function::FontStyle
174
+ styled_symbol(node)
175
+ when Function::UnaryFunction
176
+ unsupported(node) unless application_function?(node)
177
+ argument = evaluate_node(node.parameter_one)
178
+ composite("apply", with_parameters(node, argument),
179
+ view("function name #{label(node.class.new, function_name: true)}"),
180
+ role("argument", argument))
181
+ else
182
+ unsupported(node || "missing operand")
183
+ end
184
+ end
185
+
186
+ def unsupported(construct)
187
+ name = construct.is_a?(String) ? construct : construct.class.name.sub(/^Plurimath::Math::/, "")
188
+ raise Errors::UnsupportedSemanticTreeError, name
189
+ end
190
+
191
+ private
192
+
193
+ def view(label, *children, source: nil)
194
+ View.new(label, children, source)
195
+ end
196
+
197
+ def composite(name, source, *children)
198
+ view("#{name} #{label(source)}", *children, source: source)
199
+ end
200
+
201
+ def role(name, child)
202
+ composite(name, child.source, child)
203
+ end
204
+
205
+ def sequence(left, operator, right)
206
+ Formula.new([left.source, operator, right.source].compact)
207
+ end
208
+
209
+ def with_parameters(node, *children)
210
+ copy = node.dup
211
+ children.each_with_index do |child, index|
212
+ copy.public_send("#{Core::ALL_PARAMETERS.fetch(index)}=", child.source)
213
+ end
214
+ copy
215
+ end
216
+
217
+ def lines(node, depth)
218
+ ["#{' ' * depth}|_ #{node.label}"] +
219
+ node.children.flat_map { |child| lines(child, depth + 1) }
220
+ end
221
+
222
+ # Use the existing language writers for labels. A copy isolates writer
223
+ # caches from the stored leaf; composite labels are never reparsed.
224
+ def label(node, function_name: false)
225
+ copy = node.cloned_objects
226
+ options = @options.dup
227
+ options[:formula] = @label_context
228
+ text = case @language
229
+ when :mathml
230
+ copy.dump_mathml(copy, false, options: options)
231
+ when :omml
232
+ copy.dump_omml(copy, true, options: options)
233
+ else
234
+ copy.public_send("to_#{@language}", options: options)
235
+ end
236
+ text = text.to_s
237
+ text = text.delete("⁡") if function_name
238
+ text.inspect
239
+ end
240
+
241
+ def symbol(node)
242
+ if node.operator? || node.open? || node.close? || relation_operator?(node) || application_marker?(node)
243
+ unsupported(node)
244
+ end
245
+ # Punctuation, separators and unclassified operators cannot safely be
246
+ # treated as operands. Named mathematical letters remain symbol leaves.
247
+ glyph = Utility.html_entity_to_unicode(node.to_unicodemath(options: @options.dup).to_s)
248
+ unsupported(node) unless glyph.match?(/\A[\p{L}\p{N}]+\z/) || %w[∞ ∅].include?(glyph)
249
+
250
+ view("symbol #{label(node)}", source: node)
251
+ end
252
+
253
+ def fenced(node)
254
+ left = node.parameter_one
255
+ right = node.parameter_three
256
+ unless left.nil? == right.nil? &&
257
+ (left.nil? || left.is_a?(Symbols::Paren::Lround)) &&
258
+ (right.nil? || right.is_a?(Symbols::Paren::Rround))
259
+ unsupported("Function::Fenced (non-round or unmatched delimiters)")
260
+ end
261
+ group(evaluate_node(node.parameter_two))
262
+ end
263
+
264
+ def styled_symbol(node)
265
+ child = node.parameter_one
266
+ if child.is_a?(Function::Text) && child.parameter_one.to_s.match?(/\A\p{L}\z/)
267
+ copy = node.dup
268
+ copy.parameter_one = child.dup
269
+ view("symbol #{label(copy)}", source: copy)
270
+ elsif child.is_a?(Symbols::Symbol) || child.is_a?(Number)
271
+ symbol(child) if child.is_a?(Symbols::Symbol)
272
+ copy = node.dup
273
+ copy.parameter_one = child.dup
274
+ view("symbol #{label(copy)}", source: copy)
275
+ else
276
+ unsupported("#{node.class.name.sub(/^Plurimath::Math::/, '')} (non-atomic font style)")
277
+ end
278
+ end
279
+
280
+ def nary(node)
281
+ children = []
282
+ lower = evaluate_node(node.parameter_one) if node.parameter_one
283
+ upper = evaluate_node(node.parameter_two) if node.parameter_two
284
+ body = evaluate_node(node.parameter_three)
285
+ children << role("lower limit", lower) if lower
286
+ children << role("upper limit", upper) if upper
287
+ children << role(node.is_a?(Function::Int) ? "integrand" : "body", body)
288
+ source = node.dup
289
+ source.parameter_one = lower&.source
290
+ source.parameter_two = upper&.source
291
+ source.parameter_three = body.source
292
+ composite(node.class::FUNCTION.fetch(:name), source, *children)
293
+ end
294
+
295
+ def application_function?(node)
296
+ node.is_a?(Function::UnaryFunction) && APPLICATIONS.include?(node.class.name.split("::").last)
297
+ end
298
+
299
+ # A function power such as sin^2 carries the exponent on an unbound
300
+ # function node. Bind its following operand inside the power, so the
301
+ # result is (sin x)^2, while retaining the shared parser's precedence.
302
+ def unbound_application?(node)
303
+ return unbound_application?(node.parameter_one) if node.is_a?(Function::Power)
304
+
305
+ application_function?(node) &&
306
+ (node.parameter_one.nil? || application_marker?(node.parameter_one))
307
+ end
308
+
309
+ def bind_application(node, argument)
310
+ copy = node.dup
311
+ copy.parameter_one = if node.is_a?(Function::Power)
312
+ bind_application(node.parameter_one, argument)
313
+ else
314
+ argument
315
+ end
316
+ copy
317
+ end
318
+
319
+ def application_marker?(node)
320
+ node.is_a?(Symbols::Funcapply) ||
321
+ (node.instance_of?(Symbols::Symbol) && ["⁡", "&#x2061;"].include?(node.value))
322
+ end
323
+
324
+ def valid_argument!(node)
325
+ if !node.is_a?(Core) || node.operator? || node.open? || node.close? || relation_operator?(node)
326
+ unsupported("function application (missing or ambiguous argument)")
327
+ end
328
+ end
329
+ end
330
+ end
331
+ end
@@ -4,6 +4,8 @@ module Plurimath
4
4
  module Math
5
5
  module Symbols
6
6
  class Symbol < Core
7
+ extend SymbolDocumentation
8
+
7
9
  attr_accessor :value, :slashed, :mini_sub_sized, :mini_sup_sized,
8
10
  :options
9
11
 
@@ -46,11 +48,16 @@ module Plurimath
46
48
  if value&.include?("&#x2147;")
47
49
  attributes[:intent] = Utility.html_entity_to_unicode(value)
48
50
  end
49
- attributes[:rspace] = @options[:rspace] if @options&.key?(:rspace)
50
- mi_tag = ox_element("mi", attributes: attributes)
51
- return mi_tag if ["{:", ":}"].include?(value)
52
-
53
- value ? mi_tag << value : mi_tag
51
+ # `rspace` is an operator attribute: MathML 4 allows it on `mo` (and
52
+ # `mpadded`), never on `mi`. A generic token that carries one must
53
+ # therefore serialize as `mo`, whatever produced it.
54
+ rspace = @options&.[](:rspace)
55
+ attributes[:rspace] = rspace unless rspace.nil?
56
+ tag_name = rspace.nil? ? "mi" : "mo"
57
+ token_tag = ox_element(tag_name, attributes: attributes)
58
+ return token_tag if ["{:", ":}"].include?(value)
59
+
60
+ value ? token_tag << value : token_tag
54
61
  end
55
62
 
56
63
  def to_latex(**)
@@ -4,13 +4,18 @@ module Plurimath
4
4
  module Math
5
5
  autoload :Core, "#{__dir__}/math/core"
6
6
  autoload :Evaluation, "#{__dir__}/math/evaluation"
7
+ autoload :SemanticTree, "#{__dir__}/math/semantic_tree"
7
8
  autoload :Formula, "#{__dir__}/math/formula"
8
9
  autoload :ModelHelper, "#{__dir__}/math/model_helper"
9
10
  autoload :Function, "#{__dir__}/math/function"
10
11
  autoload :InvalidTypeError, "#{__dir__}/errors/invalid_type_error"
12
+ autoload :MissingDependencyError,
13
+ "#{__dir__}/errors/missing_dependency_error"
11
14
  autoload :Number, "#{__dir__}/math/number"
12
15
  autoload :ParseOptionError, "#{__dir__}/errors/parse_option_error"
13
16
  autoload :ParseError, "#{__dir__}/errors/parse_error"
17
+ autoload :Renderer, "#{__dir__}/math/renderer"
18
+ autoload :AmbiguousDecimalError, "#{__dir__}/errors/ambiguous_decimal_error"
14
19
  autoload :Symbols, "#{__dir__}/math/symbols"
15
20
 
16
21
  VALID_TYPES = {
@@ -50,6 +55,27 @@ module Plurimath
50
55
  end
51
56
  end
52
57
 
58
+ # Parse +text+ of +type+ and render the resulting formula to a binary image
59
+ # (one of :svg, :png, :pdf, :ps) via the optional `lasem` gem. Parse options
60
+ # (e.g. :locale) are routed to .parse; the rest go to Formula#render. Raises a
61
+ # Plurimath::Errors rendering error (RenderingUnavailable, UnsupportedRenderFormat,
62
+ # or RenderingFailed) when lasem/its native extension is unavailable, the format
63
+ # is unsupported, or rendering fails, and Plurimath::Math::ParseError when +text+
64
+ # cannot be parsed (raised during parsing, before rendering).
65
+ def render(text, type, format: :svg, **options)
66
+ parse_options = options.slice(*SUPPORTED_PARSE_OPTIONS)
67
+ render_options = options.except(*SUPPORTED_PARSE_OPTIONS)
68
+ parse(text, type, **parse_options).render(format: format, **render_options)
69
+ end
70
+
71
+ # True when image rendering is available (the `lasem` gem and its native
72
+ # extension are present; always false under Opal). Never raises.
73
+ def render_available?
74
+ return false if RUBY_ENGINE == "opal"
75
+
76
+ Renderer.available?
77
+ end
78
+
53
79
  def parse_with_configuration(text, type, options)
54
80
  return parse_formula(text, type) unless options.key?(:locale)
55
81
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "mml"
4
+ require_relative "uncertainty_marker"
4
5
 
5
6
  module Plurimath
6
7
  class Mathml
@@ -91,6 +92,8 @@ module Plurimath
91
92
 
92
93
  # MathML element: <mrow>
93
94
  def mrow_to_mrow(mrow)
95
+ return mrow_to_uncertainty(mrow) if uncertainty_mrow?(mrow)
96
+
94
97
  children = ordered_children(mrow)
95
98
  formula_values = children.filter_map do |child|
96
99
  mml_to_plurimath(child)
@@ -115,6 +118,35 @@ module Plurimath
115
118
  Plurimath::Math::Formula.new(mrow_obj.value)
116
119
  end
117
120
 
121
+ # An mrow whose intent is an uncertainty marker (plurimath#493). The
122
+ # marker is authoritative; the visible children are regenerated on
123
+ # output, so only the child marked arg="unit" is read.
124
+ def uncertainty_mrow?(mrow)
125
+ mrow.respond_to?(:intent) && UncertaintyMarker.marker?(mrow.intent)
126
+ end
127
+
128
+ def mrow_to_uncertainty(mrow)
129
+ UncertaintyMarker.build(mrow.intent) { uncertainty_unit(mrow) }
130
+ end
131
+
132
+ def uncertainty_unit(mrow)
133
+ units = ordered_children(mrow).select do |child|
134
+ child.respond_to?(:arg) && child.arg == "unit"
135
+ end
136
+ return if units.empty?
137
+
138
+ if units.length > 1
139
+ raise Plurimath::Errors::InvalidUncertainty.new(
140
+ "intent #{mrow.intent.inspect}: more than one arg=\"unit\" child",
141
+ )
142
+ end
143
+
144
+ unit = mml_to_plurimath(units.first)
145
+ return if unit.nil?
146
+
147
+ unit.is_a?(Plurimath::Math::Formula) ? unit : Plurimath::Math::Formula.new([unit])
148
+ end
149
+
118
150
  # MathML element: <mover> base overscript
119
151
  # Note: Overset convention is parameter_one=overscript, parameter_two=base
120
152
  # (reversed from MathML document order) to match rendering methods
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plurimath
4
+ class Mathml
5
+ # Reads the measurement-uncertainty marker (plurimath#493) shared by the
6
+ # MathML, OMML and UnicodeMath readers: the MathML `intent` of an mrow,
7
+ # the text of the hidden OMML phantom, and the UnicodeMath ⓘ("...")
8
+ # string. See docs/uncertainty.adoc.
9
+ #
10
+ # standard-uncertainty(1.230,0.004)
11
+ # standard-uncertainty:concise(1.230,0.004)
12
+ # expanded-uncertainty(10.0,0.3,2[,0.95][,$unit])
13
+ # coverage-interval[:shortest|:probabilistically-symmetric](y,lo,hi,p[,$unit])
14
+ #
15
+ # A string that starts with one of the three concept names is a marker.
16
+ # A marker that does not have exactly this shape raises
17
+ # Errors::InvalidUncertainty; it is never read as ordinary content.
18
+ module UncertaintyMarker
19
+ UNIT_REFERENCE = "$unit"
20
+
21
+ ARGUMENT_FIELDS = {
22
+ standard: [%i[magnitude]],
23
+ expanded: [%i[magnitude coverage_factor],
24
+ %i[magnitude coverage_factor coverage_probability]],
25
+ coverage_interval: [%i[lower upper coverage_probability]],
26
+ }.freeze
27
+
28
+ module_function
29
+
30
+ def names
31
+ Math::Function::Uncertainty::MARKER_NAMES
32
+ end
33
+
34
+ def name_pattern
35
+ @name_pattern ||= begin
36
+ alternatives = names.values.map { |name| Regexp.escape(name) }
37
+ /\A\s*(#{alternatives.join('|')})(?![\w-])/
38
+ end
39
+ end
40
+
41
+ # True when the text uses one of the three concept names, whether or
42
+ # not the rest of the marker is well formed.
43
+ def marker?(text)
44
+ text.is_a?(String) && name_pattern.match?(text)
45
+ end
46
+
47
+ # Builds the Uncertainty node described by the marker. `unit` is
48
+ # called only when the marker refers to `$unit`, and must return the
49
+ # unit content read from the markup.
50
+ def build(text, &unit)
51
+ fields = parse(text)
52
+ options = fields[:options]
53
+ if fields[:unit]
54
+ unit_value = unit&.call
55
+ invalid!(text, "it refers to $unit but no unit content was found") unless unit_value
56
+
57
+ options[:unit] = unit_value
58
+ end
59
+ Math::Function::Uncertainty.new(fields[:value], **options)
60
+ end
61
+
62
+ def parse(text)
63
+ match = marker_match(text)
64
+ category = names.key(match[:name])
65
+ arguments = match[:arguments].split(",", -1).map(&:strip)
66
+ unit = arguments.last == UNIT_REFERENCE
67
+ arguments.pop if unit
68
+ if arguments.any? { |argument| argument.empty? || argument.start_with?("$") }
69
+ invalid!(text, "arguments must be numbers, with $unit only as the last one")
70
+ end
71
+
72
+ value, *rest = arguments
73
+ fields = ARGUMENT_FIELDS[category].find { |list| list.length == rest.length }
74
+ invalid!(text, "wrong number of arguments for #{match[:name]}") unless fields
75
+
76
+ options = { category: category }
77
+ fields.zip(rest).each { |field, number| options[field] = Math::Number.new(number) }
78
+ options.merge!(property_options(category, match[:property], text))
79
+ { value: Math::Number.new(value), options: options, unit: unit }
80
+ end
81
+
82
+ # Whitespace follows the MathML 4 intent grammar (section 5.1):
83
+ #
84
+ # application := expression '(' arguments? S ')'
85
+ # expression := S ( term property* ) S
86
+ # property := S ':' NCName
87
+ #
88
+ # so whitespace may come before a property's ':' and between the head
89
+ # (with its property) and '(', and around each argument, but not
90
+ # between ':' and the property name.
91
+ def marker_match(text)
92
+ name = Regexp.union(names.values)
93
+ pattern = /\A\s*(?<name>#{name})(?:\s*:(?<property>[^():,\s]+))?\s*\((?<arguments>[^()]*)\)\s*\z/
94
+ pattern.match(text) || invalid!(text, "it is not of the form name[:property](arguments)")
95
+ end
96
+
97
+ def property_options(category, property, text)
98
+ case category
99
+ when :standard
100
+ return { notation: :absolute } if property.nil?
101
+ return { notation: :concise } if property == "concise"
102
+ when :expanded
103
+ return {} if property.nil?
104
+ when :coverage_interval
105
+ return {} if property.nil?
106
+
107
+ kind = Math::Function::Uncertainty::INTERVAL_KIND_NAMES.key(property)
108
+ return { interval_kind: kind } if kind
109
+ end
110
+ invalid!(text, "unknown property :#{property}")
111
+ end
112
+
113
+ def invalid!(text, detail)
114
+ raise Errors::InvalidUncertainty.new("marker #{text.inspect}: #{detail}")
115
+ end
116
+ end
117
+ end
118
+ end