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
@@ -0,0 +1,655 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plurimath
4
+ module Math
5
+ module Function
6
+ # A measured value with its uncertainty (plurimath#493).
7
+ #
8
+ # Three categories are supported: a standard uncertainty (JCGM 100
9
+ # 7.2.1, 7.2.2), a symmetric expanded uncertainty with its coverage
10
+ # factor (JCGM 100 6.2.1, 7.2.3) and a coverage interval given by its
11
+ # endpoints (JCGM 101 5.3, 5.5). The node records the reported numbers
12
+ # exactly as written; it never calculates, rounds or converts them.
13
+ # See docs/uncertainty.adoc.
14
+ class Uncertainty < Core
15
+ CATEGORIES = %i[standard expanded coverage_interval].freeze
16
+ NOTATIONS = %i[absolute concise].freeze
17
+ INTERVAL_KINDS = %i[shortest probabilistically_symmetric].freeze
18
+
19
+ SIGNED_DECIMAL = /\A-?[0-9]+(\.[0-9]+)?\z/
20
+ UNSIGNED_DECIMAL = /\A[0-9]+(\.[0-9]+)?\z/
21
+
22
+ MARKER_NAMES = {
23
+ standard: "standard-uncertainty",
24
+ expanded: "expanded-uncertainty",
25
+ coverage_interval: "coverage-interval",
26
+ }.freeze
27
+
28
+ ASCIIMATH_NAMES = {
29
+ standard: "ustd",
30
+ expanded: "uexp",
31
+ coverage_interval: "ucov",
32
+ }.freeze
33
+
34
+ INTERVAL_KIND_NAMES = {
35
+ shortest: "shortest",
36
+ probabilistically_symmetric: "probabilistically-symmetric",
37
+ }.freeze
38
+
39
+ # AsciiMath reads "-" as minus, so the symmetric kind has its own spelling.
40
+ ASCIIMATH_INTERVAL_KIND_NAMES = {
41
+ shortest: "shortest",
42
+ probabilistically_symmetric: "symmetric",
43
+ }.freeze
44
+
45
+ PLUS_MINUS = "&#xb1;"
46
+
47
+ # One unit symbol as UnitsML writes it, for example "kg".
48
+ UNIT_SYMBOL = /\A[A-Za-z]+\z/
49
+
50
+ attr_reader :value, :category, :magnitude, :coverage_factor,
51
+ :coverage_probability, :lower, :upper, :interval_kind,
52
+ :notation, :unit
53
+
54
+ # The keyword list is the documented model (docs/uncertainty.adoc).
55
+ def initialize( # rubocop:disable Metrics/ParameterLists
56
+ value,
57
+ category:,
58
+ magnitude: nil,
59
+ coverage_factor: nil,
60
+ coverage_probability: nil,
61
+ lower: nil,
62
+ upper: nil,
63
+ interval_kind: nil,
64
+ notation: :absolute,
65
+ unit: nil
66
+ )
67
+ @value = value
68
+ @category = category
69
+ @magnitude = magnitude
70
+ @coverage_factor = coverage_factor
71
+ @coverage_probability = coverage_probability
72
+ @lower = lower
73
+ @upper = upper
74
+ @interval_kind = interval_kind
75
+ @notation = category == :standard ? notation : nil
76
+ @unit = unit
77
+ super()
78
+ validate!(notation)
79
+ end
80
+
81
+ def ==(other)
82
+ other.class == self.class &&
83
+ other.category == category &&
84
+ other.value == value &&
85
+ other.magnitude == magnitude &&
86
+ other.coverage_factor == coverage_factor &&
87
+ other.coverage_probability == coverage_probability &&
88
+ other.lower == lower &&
89
+ other.upper == upper &&
90
+ other.interval_kind == interval_kind &&
91
+ other.notation == notation &&
92
+ other.unit == unit
93
+ end
94
+
95
+ # Core#cloned_objects builds `self.class.new(nil)`, which the
96
+ # validating constructor rejects, so rebuild through the constructor.
97
+ def cloned_objects
98
+ self.class.new(
99
+ value.cloned_objects,
100
+ category: category,
101
+ magnitude: magnitude&.cloned_objects,
102
+ coverage_factor: coverage_factor&.cloned_objects,
103
+ coverage_probability: coverage_probability&.cloned_objects,
104
+ lower: lower&.cloned_objects,
105
+ upper: upper&.cloned_objects,
106
+ interval_kind: interval_kind,
107
+ notation: notation || :absolute,
108
+ unit: unit&.cloned_objects,
109
+ )
110
+ end
111
+
112
+ # The node is one indivisible result; never split it across lines.
113
+ def line_breaking(_obj); end
114
+
115
+ # The marker string carried by MathML intent, the OMML phantom and
116
+ # the UnicodeMath intent form.
117
+ def marker
118
+ name = MARKER_NAMES[category]
119
+ name = "#{name}:concise" if notation == :concise
120
+ if interval_kind
121
+ name = "#{name}:#{INTERVAL_KIND_NAMES[interval_kind]}"
122
+ end
123
+ "#{name}(#{marker_arguments.join(',')})"
124
+ end
125
+
126
+ def concise_digits
127
+ digits = text(magnitude).delete(".").sub(/\A0+/, "")
128
+ digits.empty? ? "0" : digits
129
+ end
130
+
131
+ # Output
132
+
133
+ def to_asciimath(options:)
134
+ args = case category
135
+ when :standard
136
+ if notation == :concise
137
+ ["#{text(value)}(#{concise_digits})"]
138
+ else
139
+ [text(value), text(magnitude)]
140
+ end
141
+ when :expanded
142
+ [text(value), text(magnitude), text(coverage_factor),
143
+ text(coverage_probability)]
144
+ else
145
+ [text(value), text(lower), text(upper),
146
+ text(coverage_probability),
147
+ ASCIIMATH_INTERVAL_KIND_NAMES[interval_kind]]
148
+ end
149
+ unitsml = asciimath_unitsml if unit
150
+ args << "\"unitsml(#{unitsml})\"" if unitsml
151
+ call = "#{ASCIIMATH_NAMES[category]}(#{args.compact.join(', ')})"
152
+ return call if unit.nil? || unitsml
153
+
154
+ # Not expressible in UnitsML: keep it visible after the call, the
155
+ # only place the AsciiMath reader accepts it (docs/uncertainty.adoc).
156
+ "#{call} #{unit.to_asciimath(options: options)}"
157
+ end
158
+
159
+ def to_latex(options:)
160
+ unit_text = " #{unit.to_latex(options: options)}" if unit
161
+ case category
162
+ when :standard
163
+ "#{text(value)}(#{standard_parenthesised})#{unit_text}"
164
+ when :expanded
165
+ "(#{text(value)} \\pm #{text(magnitude)})#{unit_text}#{latex_statements}"
166
+ else
167
+ interval = "[#{text(lower)}, #{text(upper)}]"
168
+ "#{text(value)},\\ #{interval}#{unit_text}#{latex_statements}"
169
+ end
170
+ end
171
+
172
+ def to_html(options:)
173
+ unit_text = " #{unit.to_html(options: options)}" if unit
174
+ case category
175
+ when :standard
176
+ "#{text(value)}(#{standard_parenthesised})#{unit_text}"
177
+ when :expanded
178
+ "(#{text(value)} #{PLUS_MINUS} #{text(magnitude)})#{unit_text}#{text_statements}"
179
+ else
180
+ interval = "[#{text(lower)}, #{text(upper)}]"
181
+ "#{text(value)}, #{interval}#{unit_text}#{text_statements}"
182
+ end
183
+ end
184
+
185
+ def to_unicodemath(options:)
186
+ unit_text = "ⓐ(unit #{unit.to_unicodemath(options: options)})" if unit
187
+ visible = case category
188
+ when :standard
189
+ "#{unicodemath_value}(#{standard_parenthesised})#{unit_text}"
190
+ when :expanded
191
+ "(#{text(value)}±#{text(magnitude)})#{unit_text}#{unicodemath_statements}"
192
+ else
193
+ interval = "[#{text(lower)}, #{text(upper)}]"
194
+ "#{unicodemath_value}, #{interval}#{unit_text}#{unicodemath_statements}"
195
+ end
196
+ "ⓘ(\"#{marker}\"#{visible})"
197
+ end
198
+
199
+ def to_mathml_without_math_tag(intent, options:)
200
+ nodes = case category
201
+ when :standard
202
+ [
203
+ mn_tag(text(value)),
204
+ mathml_fence("(", ")", [mn_tag(standard_parenthesised)]),
205
+ ]
206
+ when :expanded
207
+ [
208
+ mathml_fence(
209
+ "(", ")",
210
+ [mn_tag(text(value)), mo_tag(PLUS_MINUS),
211
+ mn_tag(text(magnitude))]
212
+ ),
213
+ ]
214
+ else
215
+ [
216
+ mn_tag(text(value)),
217
+ mo_tag(","),
218
+ mathml_fence(
219
+ "[", "]",
220
+ [mn_tag(text(lower)), mo_tag(","),
221
+ mn_tag(text(upper))]
222
+ ),
223
+ ]
224
+ end
225
+ nodes << mathml_unit(intent, options) if unit
226
+ nodes.concat(mathml_statements)
227
+ mrow = ox_element("mrow", attributes: { intent: marker })
228
+ XmlHelper.update_nodes(mrow, nodes)
229
+ end
230
+
231
+ def to_omml_without_math_tag(display_style, options:)
232
+ nodes = [omml_marker]
233
+ nodes.concat(
234
+ case category
235
+ when :standard
236
+ [
237
+ omml_run(text(value)),
238
+ omml_fence("(", ")", [omml_run(standard_parenthesised)]),
239
+ ]
240
+ when :expanded
241
+ [
242
+ omml_fence(
243
+ "(", ")",
244
+ [omml_run(text(value)), omml_run(PLUS_MINUS),
245
+ omml_run(text(magnitude))]
246
+ ),
247
+ ]
248
+ else
249
+ [
250
+ omml_run(text(value)),
251
+ omml_run(","),
252
+ omml_fence(
253
+ "[", "]",
254
+ [omml_run(text(lower)), omml_run(","),
255
+ omml_run(text(upper))]
256
+ ),
257
+ ]
258
+ end,
259
+ )
260
+ nodes << omml_box(unit.insert_t_tag(display_style, options: options)) if unit
261
+ nodes.concat(statements.flat_map { |name, number| omml_statement(name, number) })
262
+ [omml_box(nodes)]
263
+ end
264
+
265
+ def to_asciimath_math_zone(spacing = "", _last = false, _indent = true,
266
+ options:)
267
+ "#{spacing}\"#{to_asciimath(options: options)}\" uncertainty\n"
268
+ end
269
+
270
+ def to_latex_math_zone(spacing = "", _last = false, _indent = true,
271
+ options:)
272
+ "#{spacing}\"#{to_latex(options: options)}\" uncertainty\n"
273
+ end
274
+
275
+ def to_mathml_math_zone(spacing = "", _last = false, _indent = true,
276
+ options:)
277
+ "#{spacing}\"#{dump_mathml(self, options: options)}\" uncertainty\n"
278
+ end
279
+
280
+ def to_omml_math_zone(spacing = "", _last = false, _indent = true,
281
+ display_style:, options:)
282
+ "#{spacing}\"#{dump_omml(self, display_style,
283
+ options: options)}\" uncertainty\n"
284
+ end
285
+
286
+ def to_unicodemath_math_zone(spacing = "", _last = false,
287
+ _indent = true, options:)
288
+ "#{spacing}\"#{to_unicodemath(options: options)}\" uncertainty\n"
289
+ end
290
+
291
+ private
292
+
293
+ def text(number)
294
+ number&.value
295
+ end
296
+
297
+ def standard_parenthesised
298
+ notation == :concise ? concise_digits : text(magnitude)
299
+ end
300
+
301
+ def marker_arguments
302
+ args = case category
303
+ when :standard
304
+ [text(value), text(magnitude)]
305
+ when :expanded
306
+ [text(value), text(magnitude), text(coverage_factor),
307
+ text(coverage_probability)]
308
+ else
309
+ [text(value), text(lower), text(upper),
310
+ text(coverage_probability)]
311
+ end
312
+ args << "$unit" if unit
313
+ args.compact
314
+ end
315
+
316
+ # The ", k = 2" and ", p = 0.95" statements, in order.
317
+ def statements
318
+ list = []
319
+ list << ["k", coverage_factor] if coverage_factor
320
+ list << ["p", coverage_probability] if coverage_probability
321
+ list
322
+ end
323
+
324
+ def latex_statements
325
+ statements.map { |name, number| ",\\ #{name} = #{text(number)}" }.join
326
+ end
327
+
328
+ def text_statements
329
+ statements.map { |name, number| ", #{name} = #{text(number)}" }.join
330
+ end
331
+
332
+ def unicodemath_statements
333
+ statements.map { |name, number| ", #{name}=#{text(number)}" }.join
334
+ end
335
+
336
+ # The UnicodeMath reader cannot take `"marker"-...`, so a negative
337
+ # leading value goes in an invisible 〖〗 group; the marker is unchanged.
338
+ def unicodemath_value
339
+ text(value).start_with?("-") ? "〖#{text(value)}〗" : text(value)
340
+ end
341
+
342
+ # The UnitsML text for the AsciiMath unit argument, or nil when the
343
+ # unit cannot be written as UnitsML. A unit read from markup is its
344
+ # rendered Formula; it is written back as UnitsML only when it is a
345
+ # `*`-separated product of upright unit symbols with optional integer
346
+ # powers, and UnitsML renders that text back to the same product.
347
+ def asciimath_unitsml
348
+ return unit.text if unit.is_a?(Unitsml)
349
+
350
+ candidate = unit_text(unit)
351
+ return unless candidate
352
+
353
+ candidate if unit_text(::Unitsml.parse(candidate).to_plurimath({})) == candidate
354
+ rescue *unitsml_parse_errors
355
+ nil
356
+ end
357
+
358
+ # What UnitsML raises for text it cannot read: Parslet::ParseFailed
359
+ # before 0.6.10, Parsanol::ParseFailed in 0.6.10 and 0.6.11, and its
360
+ # own Unitsml::Errors::ParseError after that.
361
+ def unitsml_parse_errors
362
+ [Parslet::ParseFailed,
363
+ (::Parsanol::ParseFailed if defined?(::Parsanol::ParseFailed)),
364
+ (::Unitsml::Errors::ParseError if defined?(::Unitsml::Errors::ParseError))]
365
+ .compact
366
+ end
367
+
368
+ def unit_text(node)
369
+ case node
370
+ when Math::Formula then unit_product(node.value)
371
+ when Power then unit_power(node)
372
+ else unit_symbol(node)
373
+ end
374
+ end
375
+
376
+ def unit_product(parts)
377
+ return if parts.empty? || parts.length.even?
378
+
379
+ texts = parts.each_with_index.map do |part, index|
380
+ if index.odd?
381
+ part.is_a?(Math::Symbols::Cdot) ? "*" : nil
382
+ else
383
+ unit_text(part)
384
+ end
385
+ end
386
+ texts.join unless texts.include?(nil)
387
+ end
388
+
389
+ def unit_power(node)
390
+ base = unit_symbol(node.parameter_one)
391
+ exponent = node.parameter_two
392
+ exponent = exponent.value if exponent.is_a?(Math::Formula)
393
+ exponent = Array(exponent)
394
+ sign = ""
395
+ if exponent.length == 2 && exponent.first.is_a?(Math::Symbols::Minus)
396
+ sign = "-"
397
+ exponent = exponent.drop(1)
398
+ end
399
+ number = exponent.first
400
+ return unless base && exponent.length == 1 &&
401
+ number.is_a?(Math::Number) && number.value.to_s.match?(/\A[0-9]+\z/)
402
+
403
+ "#{base}^#{sign}#{number.value}"
404
+ end
405
+
406
+ def unit_symbol(node)
407
+ node = node.parameter_one if node.is_a?(FontStyle::Normal)
408
+ string = case node
409
+ when Text then node.parameter_one
410
+ when Math::Symbols::Symbol
411
+ node.value if node.instance_of?(Math::Symbols::Symbol)
412
+ end
413
+ string if string.is_a?(String) && string.match?(UNIT_SYMBOL)
414
+ end
415
+
416
+ # MathML helpers
417
+
418
+ def mn_tag(string)
419
+ ox_element("mn") << string
420
+ end
421
+
422
+ def mi_tag(string)
423
+ ox_element("mi") << string
424
+ end
425
+
426
+ def mathml_fence(open, close, nodes)
427
+ XmlHelper.update_nodes(
428
+ ox_element("mrow"),
429
+ [mo_tag(open), *nodes, mo_tag(close)],
430
+ )
431
+ end
432
+
433
+ def mathml_statements
434
+ statements.flat_map do |name, number|
435
+ [mo_tag(","), mi_tag(name), mo_tag("="), mn_tag(text(number))]
436
+ end
437
+ end
438
+
439
+ # Unitsml marks its MathML with `unitsml`, so Formula#to_mathml puts
440
+ # the usual unit space in front of it; a unit read from markup is
441
+ # marked the same way.
442
+ def mathml_unit(intent, options)
443
+ nodes = unit.to_mathml_without_math_tag(intent, options: options)
444
+ mrow = if !nodes.is_a?(Array) && nodes.name == "mrow"
445
+ nodes
446
+ else
447
+ XmlHelper.update_nodes(ox_element("mrow"), Array(nodes))
448
+ end
449
+ mrow["unitsml"] = true unless mrow["unitsml"]
450
+ mrow["arg"] = "unit"
451
+ mrow
452
+ end
453
+
454
+ # OMML helpers
455
+
456
+ def omml_run(string)
457
+ ox_element("r", namespace: "m") << (ox_element("t", namespace: "m") << string)
458
+ end
459
+
460
+ def omml_box(nodes)
461
+ box = ox_element("box", namespace: "m")
462
+ box << XmlHelper.update_nodes(ox_element("e", namespace: "m"), nodes)
463
+ end
464
+
465
+ def omml_fence(open, close, nodes)
466
+ dpr = ox_element("dPr", namespace: "m")
467
+ dpr << ox_element("begChr", namespace: "m", attributes: { "m:val": open })
468
+ dpr << ox_element("sepChr", namespace: "m", attributes: { "m:val": "" })
469
+ dpr << ox_element("endChr", namespace: "m", attributes: { "m:val": close })
470
+ d_tag = ox_element("d", namespace: "m") << dpr
471
+ d_tag << XmlHelper.update_nodes(ox_element("e", namespace: "m"), nodes)
472
+ end
473
+
474
+ def omml_statement(name, number)
475
+ [omml_run(","), omml_run(name), omml_run("="), omml_run(text(number))]
476
+ end
477
+
478
+ def omml_marker
479
+ phant_pr = ox_element("phantPr", namespace: "m")
480
+ phant_pr << ox_element("show", namespace: "m", attributes: { "m:val": "off" })
481
+ %w[zeroWid zeroAsc zeroDesc].each do |name|
482
+ phant_pr << ox_element(name, namespace: "m", attributes: { "m:val": "on" })
483
+ end
484
+ phant = ox_element("phant", namespace: "m") << phant_pr
485
+ phant << XmlHelper.update_nodes(
486
+ ox_element("e", namespace: "m"),
487
+ r_element(marker),
488
+ )
489
+ end
490
+
491
+ # Validation
492
+
493
+ def validate!(notation)
494
+ unless CATEGORIES.include?(category)
495
+ invalid!("unknown category #{category.inspect}")
496
+ end
497
+
498
+ validate_number(:value, value, signed: true)
499
+ %i[magnitude coverage_factor coverage_probability lower upper].each do |field|
500
+ number = public_send(field)
501
+ validate_number(field, number, signed: false) unless number.nil?
502
+ end
503
+ validate_unit
504
+ send(:"validate_#{category}", notation)
505
+ end
506
+
507
+ def validate_standard(notation)
508
+ forbid(:coverage_factor, :coverage_probability, :lower, :upper,
509
+ :interval_kind)
510
+ require_fields(:magnitude)
511
+ unless NOTATIONS.include?(notation)
512
+ invalid!("unknown notation #{notation.inspect}")
513
+ end
514
+ return unless notation == :concise
515
+ return if decimal_places(text(magnitude)) == decimal_places(text(value))
516
+
517
+ invalid!(
518
+ "magnitude #{text(magnitude)} cannot be written concisely after " \
519
+ "#{text(value)}: it needs as many decimal places as the value",
520
+ )
521
+ end
522
+
523
+ def validate_expanded(notation)
524
+ forbid(:lower, :upper, :interval_kind)
525
+ validate_unqualified_notation(notation)
526
+ require_fields(:magnitude, :coverage_factor)
527
+ validate_coverage_factor
528
+ validate_coverage_probability
529
+ end
530
+
531
+ def validate_coverage_interval(notation)
532
+ forbid(:magnitude, :coverage_factor)
533
+ validate_unqualified_notation(notation)
534
+ require_fields(:lower, :upper, :coverage_probability)
535
+ unless interval_kind.nil? || INTERVAL_KINDS.include?(interval_kind)
536
+ invalid!("unknown interval kind #{interval_kind.inspect}")
537
+ end
538
+ validate_coverage_probability
539
+ return if compare_decimals(text(lower), text(upper)).negative?
540
+
541
+ invalid!("lower endpoint #{text(lower)} is not below upper endpoint #{text(upper)}")
542
+ end
543
+
544
+ def validate_unqualified_notation(notation)
545
+ return if notation.nil? || notation == :absolute
546
+
547
+ invalid!("notation #{notation.inspect} applies only to a standard uncertainty")
548
+ end
549
+
550
+ def validate_coverage_factor
551
+ return if positive?(text(coverage_factor))
552
+
553
+ invalid!("coverage factor #{text(coverage_factor)} must be greater than 0")
554
+ end
555
+
556
+ def validate_coverage_probability
557
+ return if coverage_probability.nil?
558
+
559
+ probability = text(coverage_probability)
560
+ return if positive?(probability) && compare_decimals(probability, "1").negative?
561
+
562
+ invalid!("coverage probability #{probability} must lie strictly between 0 and 1")
563
+ end
564
+
565
+ def validate_number(field, number, signed:)
566
+ pattern = signed ? SIGNED_DECIMAL : UNSIGNED_DECIMAL
567
+ kind = signed ? "decimal" : "unsigned decimal"
568
+ valid = number.is_a?(Math::Number) &&
569
+ number.value.is_a?(String) &&
570
+ number.value.match?(pattern) &&
571
+ !number.mini_sized? &&
572
+ number.base.nil?
573
+ return if valid
574
+
575
+ invalid!(
576
+ "#{field} must be a Math::Number holding a plain #{kind}, " \
577
+ "got #{number.inspect}",
578
+ )
579
+ end
580
+
581
+ def validate_unit
582
+ return if unit.nil? || unit.is_a?(Unitsml)
583
+
584
+ unless unit.is_a?(Math::Formula)
585
+ invalid!("unit must be a Math::Function::Unitsml, got #{unit.inspect}")
586
+ end
587
+ invalid!("unit must not be empty") if empty_formula?(unit)
588
+ return unless holds_uncertainty?(unit)
589
+
590
+ invalid!("unit must not contain an uncertainty")
591
+ end
592
+
593
+ def empty_formula?(formula)
594
+ Array(formula.value).compact.all? do |part|
595
+ part.is_a?(Math::Formula) && empty_formula?(part)
596
+ end
597
+ end
598
+
599
+ def holds_uncertainty?(node)
600
+ case node
601
+ when Uncertainty then true
602
+ when Array then node.any? { |item| holds_uncertainty?(item) }
603
+ when Math::Core
604
+ node.instance_variables.any? do |name|
605
+ holds_uncertainty?(node.instance_variable_get(name))
606
+ end
607
+ else false
608
+ end
609
+ end
610
+
611
+ def forbid(*fields)
612
+ fields.each do |field|
613
+ next if public_send(field).nil?
614
+
615
+ invalid!("#{field} does not apply to a #{category} uncertainty")
616
+ end
617
+ end
618
+
619
+ def require_fields(*fields)
620
+ fields.each do |field|
621
+ next unless public_send(field).nil?
622
+
623
+ invalid!("#{field} is required for a #{category} uncertainty")
624
+ end
625
+ end
626
+
627
+ def invalid!(message)
628
+ raise Errors::InvalidUncertainty.new(message)
629
+ end
630
+
631
+ def decimal_places(string)
632
+ string.include?(".") ? string.split(".", 2).last.length : 0
633
+ end
634
+
635
+ def positive?(string)
636
+ string.match?(/[1-9]/)
637
+ end
638
+
639
+ # Exact comparison of two unsigned plain decimals, without Float.
640
+ def compare_decimals(first, second)
641
+ first_int, first_frac = first.split(".", 2)
642
+ second_int, second_frac = second.split(".", 2)
643
+ first_int = first_int.sub(/\A0+/, "")
644
+ second_int = second_int.sub(/\A0+/, "")
645
+ by_length = first_int.length <=> second_int.length
646
+ return by_length unless by_length.zero?
647
+
648
+ width = [first_frac.to_s.length, second_frac.to_s.length].max
649
+ "#{first_int}#{first_frac.to_s.ljust(width, '0')}" <=>
650
+ "#{second_int}#{second_frac.to_s.ljust(width, '0')}"
651
+ end
652
+ end
653
+ end
654
+ end
655
+ end
@@ -4,6 +4,12 @@ module Plurimath
4
4
  module Math
5
5
  module Function
6
6
  class Underover < TernaryFunction
7
+ # --- Catalog documentation (see Plurimath::Documentation) ---
8
+ DESCRIPTION = "A base with expressions attached directly below and above it."
9
+ REFERENCE = "https://developer.mozilla.org/en-US/docs/Web/MathML/Reference/Element/munderover"
10
+ EXAMPLE = -> { new(sym("x"), sym("y"), sym("z")) }
11
+ # --- end catalog documentation ---
12
+
7
13
  FUNCTION = {
8
14
  name: "UnderOver",
9
15
  first_value: "base",
@@ -4,6 +4,12 @@ module Plurimath
4
4
  module Math
5
5
  module Function
6
6
  class Underset < BinaryFunction
7
+ # --- Catalog documentation (see Plurimath::Documentation) ---
8
+ DESCRIPTION = "An expression set as an underscript below a base."
9
+ REFERENCE = "https://developer.mozilla.org/en-US/docs/Web/MathML/Reference/Element/munder"
10
+ EXAMPLE = -> { new(sym("x"), sym("y")) }
11
+ # --- end catalog documentation ---
12
+
7
13
  attr_accessor :options
8
14
 
9
15
  FUNCTION = {
@@ -4,6 +4,12 @@ module Plurimath
4
4
  module Math
5
5
  module Function
6
6
  class Vec < UnaryFunction
7
+ # --- Catalog documentation (see Plurimath::Documentation) ---
8
+ DESCRIPTION = "A right-arrow (vector) accent over an expression."
9
+ REFERENCE = "https://developer.mozilla.org/en-US/docs/Web/MathML/Reference/Element/mover"
10
+ EXAMPLE = -> { new(sym("x")) }
11
+ # --- end catalog documentation ---
12
+
7
13
  attr_accessor :attributes
8
14
 
9
15
  def initialize(parameter_one = nil, attributes = {})