plurimath 0.11.12 → 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 +2 -0
  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
@@ -1172,6 +1172,224 @@ module Plurimath
1172
1172
  Utility.asciimath_symbol_object(right),
1173
1173
  )
1174
1174
  end
1175
+
1176
+ # ustd(...), uexp(...) and ucov(...) (plurimath#493); the tree shape
1177
+ # is docs/uncertainty-asciimath-tree.md.
1178
+ rule(uncertainty: subtree(:uncertainty)) do
1179
+ UncertaintyTree.node(uncertainty)
1180
+ end
1181
+
1182
+ # `sqrt ustd(1,2)`, `sin ustd(1,2)`: the unary rule splices its
1183
+ # unnamed operand next to `unary_class`, so the uncertainty tree is a
1184
+ # sibling key. Built like `sqrt x` (`unary_class` + `symbol`).
1185
+ rule(unary_class: simple(:function),
1186
+ uncertainty: subtree(:uncertainty)) do
1187
+ Utility.get_class(function).new(UncertaintyTree.node(uncertainty))
1188
+ end
1189
+
1190
+ # Parslet::Transform#apply, without recursion: it transforms every
1191
+ # child left to right, then the node itself, exactly as Parslet does,
1192
+ # but keeps its own stack. A long AsciiMath sum is a tree about two
1193
+ # levels deep per term, which overflows a small (1 MiB, as on Windows)
1194
+ # stack when each level is a Ruby call.
1195
+ def apply(obj, context = nil)
1196
+ return transform_elt(obj, context) unless obj.is_a?(Hash) || obj.is_a?(Array)
1197
+
1198
+ stack = [apply_frame(obj)]
1199
+ loop do
1200
+ frame = stack.last
1201
+ if frame[:index] < frame[:items].size
1202
+ key, value = frame[:items][frame[:index]]
1203
+ frame[:index] += 1
1204
+ next stack.push(apply_frame(value, key)) if value.is_a?(Hash) || value.is_a?(Array)
1205
+
1206
+ store_applied(frame[:out], key, transform_elt(value, context))
1207
+ else
1208
+ stack.pop
1209
+ result = transform_elt(frame[:out], context)
1210
+ return result if stack.empty?
1211
+
1212
+ store_applied(stack.last[:out], frame[:key], result)
1213
+ end
1214
+ end
1215
+ end
1216
+
1217
+ def apply_frame(node, key = nil)
1218
+ items = node.is_a?(Hash) ? node.to_a : node.each_with_index.map { |v, i| [i, v] }
1219
+ { items: items, out: node.is_a?(Hash) ? {} : [], index: 0, key: key }
1220
+ end
1221
+
1222
+ def store_applied(out, key, value)
1223
+ out.is_a?(Hash) ? out[key] = value : out << value
1224
+ end
1225
+
1226
+ private :apply_frame, :store_applied
1227
+
1228
+ # A hash that comes back from Parslet::Transform#transform_elt matched no
1229
+ # rule. One that still holds an uncertainty tree or node would reach
1230
+ # the Formula as a raw Hash and fail only on output, so it is
1231
+ # rejected here instead. This hook runs after child traversal, once per
1232
+ # node, as #apply calls it.
1233
+ def transform_elt(obj, context)
1234
+ result = super
1235
+ UncertaintyTree.stranded!(result) if result.is_a?(Hash)
1236
+ result
1237
+ end
1238
+
1239
+ # Builds Math::Function::Uncertainty from the fields of an
1240
+ # `uncertainty:` tree. Every leaf is the text exactly as written; the
1241
+ # node's constructor does all validation of numbers and categories.
1242
+ module UncertaintyTree
1243
+ CATEGORIES = {
1244
+ "ustd" => :standard,
1245
+ "uexp" => :expanded,
1246
+ "ucov" => :coverage_interval,
1247
+ }.freeze
1248
+
1249
+ FIELDS = {
1250
+ standard: %i[value magnitude concise_digits unit],
1251
+ expanded: %i[value magnitude coverage_factor coverage_probability
1252
+ unit],
1253
+ coverage_interval: %i[value lower upper coverage_probability
1254
+ interval_kind unit],
1255
+ }.freeze
1256
+
1257
+ NUMBER_FIELDS = %i[magnitude coverage_factor coverage_probability
1258
+ lower upper].freeze
1259
+ SCALED_FIELDS = %i[magnitude lower upper].freeze
1260
+ MAX_SCALED_DECIMAL_LENGTH = 1_000_000
1261
+
1262
+ # `probabilistically-symmetric` would read as a subtraction in
1263
+ # AsciiMath, so it is spelled `symmetric` there.
1264
+ INTERVAL_KINDS = {
1265
+ "shortest" => :shortest,
1266
+ "symmetric" => :probabilistically_symmetric,
1267
+ }.freeze
1268
+
1269
+ module_function
1270
+
1271
+ def node(tree)
1272
+ fields = leaves(tree)
1273
+ category = CATEGORIES[fields.delete(:uncertainty_class)]
1274
+ invalid!("unknown uncertainty class in #{tree.inspect}") unless category
1275
+ exponent = fields.delete(:shared_exponent)
1276
+ unknown = fields.keys - FIELDS[category]
1277
+ invalid!("unexpected #{unknown.join(', ')} in #{tree.inspect}") unless unknown.empty?
1278
+
1279
+ # Concise digits describe the unscaled value's last decimal place.
1280
+ # Derive their magnitude before moving either decimal point.
1281
+ attributes = options(fields)
1282
+ value = number(fields[:value])
1283
+ if exponent
1284
+ invalid!("invalid shared exponent #{exponent.inspect}") unless exponent.match?(/\A[+-]?[0-9]+\z/)
1285
+ power = Integer(exponent, 10)
1286
+ value = scale_number(value, power)
1287
+ SCALED_FIELDS.each do |field|
1288
+ attributes[field] = scale_number(attributes[field], power)
1289
+ end
1290
+ end
1291
+ Math::Function::Uncertainty.new(
1292
+ value,
1293
+ category: category,
1294
+ **attributes,
1295
+ )
1296
+ end
1297
+
1298
+ # Keep the model's plain-decimal contract. A shared exponent changes
1299
+ # text positions only; coverage factors/probabilities never pass here.
1300
+ def scale_number(node, power)
1301
+ return node if node.nil? || power.zero?
1302
+
1303
+ match = node.value.match(/\A(-?)([0-9]+)(?:\.([0-9]+))?\z/)
1304
+ invalid!("invalid scaled decimal #{node.value.inspect}") unless match
1305
+ sign, whole, fraction = match.captures
1306
+ digits = whole + fraction.to_s
1307
+ point = whole.length + power
1308
+ length = if point <= 0
1309
+ 2 - point + digits.length
1310
+ elsif point >= digits.length
1311
+ point
1312
+ else
1313
+ digits.length + 1
1314
+ end
1315
+ if length + sign.length > MAX_SCALED_DECIMAL_LENGTH
1316
+ invalid!("shared exponent expands beyond #{MAX_SCALED_DECIMAL_LENGTH} decimal characters")
1317
+ end
1318
+ text = if point <= 0
1319
+ "0.#{'0' * -point}#{digits}"
1320
+ elsif point >= digits.length
1321
+ digits + "0" * (point - digits.length)
1322
+ else
1323
+ "#{digits[0...point]}.#{digits[point..]}"
1324
+ end
1325
+ number(sign + text.sub(/\A0+(?=[0-9])/, ""))
1326
+ end
1327
+
1328
+ def leaves(tree)
1329
+ valid = tree.is_a?(Hash) &&
1330
+ tree.values.all? { |leaf| leaf.is_a?(Parslet::Slice) || leaf.is_a?(String) }
1331
+ invalid!("expected a hash of text fields, got #{tree.inspect}") unless valid
1332
+
1333
+ tree.transform_values(&:to_s)
1334
+ end
1335
+
1336
+ def options(fields)
1337
+ options = NUMBER_FIELDS.to_h { |field| [field, number(fields[field])] }
1338
+ options[:interval_kind] = interval_kind(fields[:interval_kind])
1339
+ options[:unit] = fields[:unit] && Math::Function::Unitsml.new(fields[:unit])
1340
+ concise!(options, fields) if fields.key?(:concise_digits)
1341
+ options
1342
+ end
1343
+
1344
+ def interval_kind(name)
1345
+ return if name.nil?
1346
+
1347
+ INTERVAL_KINDS.fetch(name) { invalid!("unknown interval kind #{name.inspect}") }
1348
+ end
1349
+
1350
+ # ustd(1.230(4)): the digits count in units of the value's last
1351
+ # decimal place, so 4 after 1.230 is 0.004.
1352
+ def concise!(options, fields)
1353
+ invalid!("ustd takes a magnitude or concise digits, not both") if fields.key?(:magnitude)
1354
+ digits = fields[:concise_digits]
1355
+ invalid!("concise digits #{digits.inspect} must be digits only") unless digits.match?(/\A[0-9]+\z/)
1356
+
1357
+ options[:magnitude] = number(concise_magnitude(fields[:value].to_s, digits))
1358
+ options[:notation] = :concise
1359
+ end
1360
+
1361
+ def concise_magnitude(value, digits)
1362
+ places = value.include?(".") ? value.split(".", 2).last.length : 0
1363
+ padded = digits.rjust(places + 1, "0")
1364
+ whole = padded[0, padded.length - places].sub(/\A0+(?=[0-9])/, "")
1365
+ places.zero? ? whole : "#{whole}.#{padded[-places..]}"
1366
+ end
1367
+
1368
+ def number(text)
1369
+ text && Math::Number.new(text)
1370
+ end
1371
+
1372
+ def stranded!(hash)
1373
+ return unless holds_uncertainty?(hash)
1374
+
1375
+ invalid!("no AsciiMath rule takes an uncertainty in #{hash.inspect}")
1376
+ end
1377
+
1378
+ def holds_uncertainty?(tree)
1379
+ case tree
1380
+ when Hash
1381
+ tree.key?(:uncertainty) || tree.each_value.any? { |v| holds_uncertainty?(v) }
1382
+ when Array
1383
+ tree.any? { |v| holds_uncertainty?(v) }
1384
+ else
1385
+ tree.is_a?(Math::Function::Uncertainty)
1386
+ end
1387
+ end
1388
+
1389
+ def invalid!(detail)
1390
+ raise Errors::InvalidUncertainty.new(detail)
1391
+ end
1392
+ end
1175
1393
  end
1176
1394
  end
1177
1395
  end
@@ -5,6 +5,7 @@ module Plurimath
5
5
  autoload :Constants, "#{__dir__}/asciimath/constants"
6
6
  autoload :Parse, "#{__dir__}/asciimath/parse"
7
7
  autoload :Parser, "#{__dir__}/asciimath/parser"
8
+ autoload :SliceAdapter, "#{__dir__}/asciimath/slice_adapter"
8
9
  autoload :Transform, "#{__dir__}/asciimath/transform"
9
10
  autoload :Utility, "#{__dir__}/asciimath/utility"
10
11
 
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plurimath
4
+ module Catalog
5
+ # Builds YAML-ready entries without changing the legacy rendering fields.
6
+ module Entry
7
+ RENDERERS = {
8
+ "asciimath" => :to_asciimath,
9
+ "latexmath" => :to_latex,
10
+ "mathml" => :to_mathml,
11
+ "omml" => :to_omml,
12
+ }.freeze
13
+ EMPTY_OUTPUT_NOTE = "The serializer returned no output."
14
+ METADATA_FIELDS = %i[name type kind arity description reference].freeze
15
+
16
+ module_function
17
+
18
+ def build(formula:, limitations: {}, **attributes)
19
+ entry = metadata(attributes)
20
+ Validation.validate_metadata!(entry)
21
+ declarations = normalize_limitations(limitations)
22
+ unless RENDERERS.values.all? { |method| formula.respond_to?(method) }
23
+ raise InvalidEntryError, "#{entry['name']}: example must support all four serializers"
24
+ end
25
+
26
+ formats = {}
27
+ RENDERERS.each do |format, method|
28
+ entry[format] = formula.public_send(method)
29
+ formats[format] = format_metadata(entry[format], declarations[format])
30
+ end
31
+ entry["formats"] = formats
32
+ Validation.validate!(entry)
33
+ entry
34
+ end
35
+
36
+ def metadata(attributes)
37
+ unless (attributes.keys - METADATA_FIELDS).empty?
38
+ raise InvalidEntryError, "Unknown catalog metadata fields"
39
+ end
40
+
41
+ entry = {
42
+ "name" => attributes[:name], "type" => label(attributes[:type]),
43
+ "kind" => label(attributes[:kind]), "arity" => label(attributes[:arity])
44
+ }
45
+ if entry["kind"] == "function"
46
+ entry["description"] = attributes[:description]
47
+ entry["reference"] = attributes[:reference]
48
+ end
49
+ entry
50
+ end
51
+
52
+ def normalize_limitations(limitations)
53
+ unless limitations.is_a?(Hash)
54
+ raise InvalidEntryError, "CATALOG_FORMAT_LIMITATIONS must be a Hash"
55
+ end
56
+
57
+ limitations.each_with_object({}) do |(format, declaration), result|
58
+ key = label(format)
59
+ unless RENDERERS.key?(key) && !result.key?(key)
60
+ raise InvalidEntryError, "Unknown or duplicate limitation format: #{format.inspect}"
61
+ end
62
+
63
+ result[key] = normalize_declaration(declaration, key)
64
+ end
65
+ end
66
+
67
+ def normalize_declaration(declaration, format)
68
+ unless declaration.is_a?(Hash)
69
+ raise InvalidEntryError, "#{format} limitation must be a Hash"
70
+ end
71
+
72
+ normalized = declaration.transform_keys { |key| label(key) }
73
+ unless normalized.keys.all?(String) && normalized.keys.sort == %w[notes status] &&
74
+ normalized.size == declaration.size
75
+ raise InvalidEntryError, "#{format} limitation requires only status and notes"
76
+ end
77
+
78
+ status = label(normalized["status"])
79
+ unless %w[limited unavailable].include?(status)
80
+ raise InvalidEntryError, "#{format} limitation status must be limited or unavailable"
81
+ end
82
+
83
+ Validation.validate_notes!(normalized["notes"], format, required: true)
84
+ { "status" => status, "notes" => normalized["notes"].map(&:dup) }
85
+ end
86
+
87
+ def format_metadata(output, declaration)
88
+ result = declaration || { "status" => "available", "notes" => [] }
89
+ if output.is_a?(String) && output.strip.empty?
90
+ result = {
91
+ "status" => "unavailable",
92
+ "notes" => (result["notes"] + [EMPTY_OUTPUT_NOTE]).uniq,
93
+ }
94
+ end
95
+ result
96
+ end
97
+
98
+ def label(value)
99
+ return if value.nil?
100
+ return value.to_s if value.is_a?(String) || value.is_a?(Symbol)
101
+
102
+ raise InvalidEntryError, "Catalog labels must be strings or symbols"
103
+ end
104
+
105
+ private_class_method :metadata, :normalize_limitations,
106
+ :normalize_declaration, :format_metadata, :label
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plurimath
4
+ module Catalog
5
+ # Validates the public data contract independently of class discovery.
6
+ module Validation
7
+ FORMATS = %w[asciimath latexmath mathml omml].freeze
8
+ ARITIES = %w[unary binary ternary].freeze
9
+ STATUSES = %w[available limited unavailable].freeze
10
+ COMMON_KEYS = %w[name type kind arity formats].freeze
11
+
12
+ module_function
13
+
14
+ def validate_all!(entries)
15
+ unless entries.is_a?(Array)
16
+ raise InvalidEntryError, "Catalog entries must be an Array"
17
+ end
18
+
19
+ seen = {}
20
+ entries.each do |entry|
21
+ validate!(entry)
22
+ identity = entry.values_at("kind", "name")
23
+ if seen[identity]
24
+ raise InvalidEntryError, "Duplicate catalog identity: #{identity.join('/')}"
25
+ end
26
+
27
+ seen[identity] = true
28
+ end
29
+ entries
30
+ end
31
+
32
+ def validate!(entry)
33
+ validate_metadata!(entry)
34
+ expected = COMMON_KEYS + FORMATS
35
+ expected += %w[description reference] if entry["kind"] == "function"
36
+ unless entry.keys.all?(String) && entry.keys.sort == expected.sort
37
+ raise InvalidEntryError, "#{entry['name']}: unexpected or missing entry fields"
38
+ end
39
+
40
+ formats = entry["formats"]
41
+ unless formats.is_a?(Hash) && formats.keys.all?(String) &&
42
+ formats.keys.sort == FORMATS.sort
43
+ raise InvalidEntryError, "#{entry['name']}: formats must contain all four formats"
44
+ end
45
+
46
+ FORMATS.each { |format| validate_format!(entry, format) }
47
+ entry
48
+ end
49
+
50
+ def validate_metadata!(entry)
51
+ unless entry.is_a?(Hash) && entry["name"].is_a?(String) &&
52
+ entry["name"].match?(/\A[a-z0-9][a-z0-9_-]*\z/)
53
+ raise InvalidEntryError, "Catalog name must be a nonempty, safe lowercase slug"
54
+ end
55
+
56
+ case entry["kind"]
57
+ when "function" then validate_function!(entry)
58
+ when "symbol"
59
+ unless entry["type"] == "symbol" && entry["arity"].nil?
60
+ raise InvalidEntryError, "#{entry['name']}: symbol type must be symbol and arity nil"
61
+ end
62
+ else
63
+ raise InvalidEntryError, "#{entry['name']}: kind must be function or symbol"
64
+ end
65
+ entry
66
+ end
67
+
68
+ def validate_notes!(notes, format, required:)
69
+ unless notes.is_a?(Array) && notes.all? { |note| present_string?(note) } &&
70
+ (!required || !notes.empty?)
71
+ raise InvalidEntryError, "#{format}: notes must be nonempty strings#{' with a reason' if required}"
72
+ end
73
+ end
74
+
75
+ def validate_function!(entry)
76
+ unless ARITIES.include?(entry["arity"]) && entry["type"] == entry["arity"]
77
+ raise InvalidEntryError, "#{entry['name']}: function type and reference arity must agree"
78
+ end
79
+ unless present_string?(entry["description"]) &&
80
+ entry["reference"].is_a?(String) && entry["reference"].match?(%r{\Ahttps?://[^\s/?#]+(?:[/?#]\S*)?\z})
81
+ raise InvalidEntryError, "#{entry['name']}: function requires a description and HTTP(S) reference"
82
+ end
83
+ end
84
+
85
+ def validate_format!(entry, format)
86
+ output = entry[format]
87
+ record = entry["formats"][format]
88
+ unless output.is_a?(String) && record.is_a?(Hash) &&
89
+ record.keys.all?(String) &&
90
+ record.keys.sort == %w[notes status] && STATUSES.include?(record["status"])
91
+ raise InvalidEntryError, "#{entry['name']}/#{format}: invalid output or format metadata"
92
+ end
93
+
94
+ unavailable = record["status"] == "unavailable"
95
+ if output.strip.empty? && !unavailable
96
+ raise InvalidEntryError, "#{entry['name']}/#{format}: empty output must be unavailable"
97
+ end
98
+
99
+ validate_notes!(record["notes"], format, required: record["status"] != "available")
100
+ end
101
+
102
+ def present_string?(value)
103
+ value.is_a?(String) && !value.strip.empty?
104
+ end
105
+
106
+ private_class_method :validate_function!, :validate_format!, :present_string?
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plurimath
4
+ # The documented symbol/function catalog that plurimath.org generates its
5
+ # reference pages from. Each entry carries a name, a type, and the example
6
+ # rendered to AsciiMath, LaTeX, MathML and OMML; function entries also carry a
7
+ # description and reference. See Plurimath::Documentation (functions) and
8
+ # Plurimath::SymbolDocumentation (symbols).
9
+ #
10
+ # Plurimath::Catalog.entries.each do |e|
11
+ # dir = e["type"] == "symbol" ? "symbols" : "functions"
12
+ # File.write("_data/#{dir}/#{e['name']}.yaml", e.to_yaml)
13
+ # end
14
+ module Catalog
15
+ autoload :Entry, "plurimath/catalog/entry"
16
+ autoload :Validation, "plurimath/catalog/validation"
17
+
18
+ class InvalidEntryError < ArgumentError; end
19
+
20
+ module_function
21
+
22
+ # Every documented class, sorted by catalog name — with catalog type as a
23
+ # tiebreaker, since a symbol and a function can share a name (e.g. `bar`) —
24
+ # for stable, totally-ordered output.
25
+ # descendants_of returns a base's descendants but not the base itself, so
26
+ # each base is enumerated alongside them — otherwise a base that is itself
27
+ # documentable is missed (Table's own `table` page; Nary, which is both
28
+ # documentable and has no descendants). Abstract bases are dropped by
29
+ # documented?: the arity classes declare no example, and the Symbol and
30
+ # Paren bases render nothing.
31
+ def classes
32
+ ensure_documentable_classes_loaded
33
+ documentable_bases
34
+ .flat_map { |base| [base, *descendants_of(base)] }
35
+ .uniq
36
+ .select(&:documented?)
37
+ .sort_by { |klass| [klass.catalog_name, klass.catalog_type.to_s] }
38
+ end
39
+
40
+ def each(&block)
41
+ classes.each(&block)
42
+ end
43
+
44
+ # YAML-ready hashes, one per documented class.
45
+ def entries
46
+ result = classes.map(&:catalog_entry)
47
+ Validation.validate_all!(result)
48
+ result
49
+ end
50
+
51
+ # Base classes whose documented descendants (and the bases themselves) are
52
+ # catalogued: the function arities, the table/n-ary bases, and the symbol tree.
53
+ def documentable_bases
54
+ [
55
+ Math::Function::TernaryFunction,
56
+ Math::Function::BinaryFunction,
57
+ Math::Function::UnaryFunction,
58
+ Math::Function::Table,
59
+ Math::Function::Nary,
60
+ Math::Symbols::Symbol,
61
+ ]
62
+ end
63
+
64
+ # descendants only sees loaded classes, so require the source files of every
65
+ # documentable base before enumerating — the function tree and the symbol
66
+ # tree. Memoized so repeated catalog calls don't re-glob.
67
+ def ensure_documentable_classes_loaded
68
+ return if @documentable_classes_loaded
69
+
70
+ # Dir.glob/require need a filesystem; under Opal the trees are build-loaded,
71
+ # so skip enumeration there rather than crash (mirrors symbols.rb).
72
+ if RUBY_ENGINE != "opal"
73
+ # math/symbols.rb loads the Symbol base (and nested bases) before their
74
+ # subclasses; a raw sorted Dir.glob would require symbols/alpha before
75
+ # symbols/symbol and raise NameError. The function tree loads its bases
76
+ # first, so glob it directly.
77
+ require File.join(__dir__, "math", "symbols")
78
+ Dir.glob(File.join(__dir__, "math", "function", "**", "*.rb")).each do |file|
79
+ require file
80
+ end
81
+ end
82
+ @documentable_classes_loaded = true
83
+ end
84
+
85
+ # descendants lists only direct subclasses, so walk the whole subtree —
86
+ # nested families (FontStyle's styles, the Paren delimiters) live below
87
+ # their group base.
88
+ def descendants_of(klass)
89
+ Array(klass.descendants)
90
+ .flat_map { |descendant| [descendant, *descendants_of(descendant)] }
91
+ end
92
+
93
+ private_class_method :documentable_bases, :ensure_documentable_classes_loaded,
94
+ :descendants_of
95
+ end
96
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plurimath
4
+ class Cli < Thor
5
+ # Standalone command object for `plurimath convert`. Usable on its own:
6
+ # Plurimath::Cli::Convert.new(options).call
7
+ class Convert
8
+ include Helpers
9
+
10
+ def call
11
+ text = input_string
12
+ warn_and_exit("missing generator argument --input or --file-input") unless text
13
+
14
+ input_format = options[:input_format]
15
+ output_format = options[:output_format]
16
+ configure_xml_engine(input_format, output_format)
17
+ formula = Plurimath::Math.parse(text, input_format)
18
+ return puts formula.to_display(output_format.to_sym) if options[:math_rendering].to_s == "true"
19
+
20
+ puts convert_formula(formula, output_format)
21
+ end
22
+
23
+ private
24
+
25
+ def convert_formula(formula, output_format)
26
+ display_style = options[:display_style]
27
+ split = options[:split_on_linebreak]
28
+ style = display_style.to_s.empty? ? "true" : display_style
29
+ case output_format
30
+ when "unicodemath" then formula.to_unicodemath
31
+ when "asciimath" then formula.to_asciimath
32
+ when "mathml" then formula.to_mathml(display_style: style, split_on_linebreak: split)
33
+ when "latex" then formula.to_latex
34
+ when "omml" then formula.to_omml(display_style: style, split_on_linebreak: split)
35
+ else warn_and_exit("Invalid output format: #{output_format}")
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plurimath
4
+ class Cli < Thor
5
+ # Shared behavior for CLI command objects (Cli::Convert, Cli::Render):
6
+ # holds the parsed Thor +options+ and the helpers both commands need.
7
+ module Helpers
8
+ attr_reader :options
9
+
10
+ def initialize(options)
11
+ super()
12
+ @options = options
13
+ end
14
+
15
+ # Resolve the raw input string from either --file-path or --input.
16
+ def input_string
17
+ options[:file_path] ? File.read(options[:file_path]) : options[:input]
18
+ end
19
+
20
+ def configure_xml_engine(input_format, output_format)
21
+ xml_formats = %w[mathml omml]
22
+ return unless xml_formats.include?(input_format) || xml_formats.include?(output_format)
23
+
24
+ set_xml_engine(options[:xml_engine] || "ox")
25
+ end
26
+
27
+ def set_xml_engine(engine)
28
+ engine_class = case engine
29
+ when "ox"
30
+ require_relative "../setup/ox_engine"
31
+ Plurimath::XmlEngine::OxEngine
32
+ when "oga"
33
+ require_relative "../setup/oga"
34
+ Plurimath::XmlEngine::Oga
35
+ else
36
+ warn_and_exit("Invalid XML engine: #{engine}. Use 'ox' or 'oga'.")
37
+ end
38
+ Plurimath.xml_engine = engine_class
39
+ end
40
+
41
+ # Print a message and exit non-zero. Uses exit(1) rather than abort so a
42
+ # rescued exception is not re-printed as a SystemExit cause backtrace.
43
+ def warn_and_exit(message)
44
+ warn(message)
45
+ exit(1)
46
+ end
47
+ end
48
+ end
49
+ end