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.
- checksums.yaml +4 -4
- data/.github/workflows/dependent-tests.yml +7 -0
- data/.github/workflows/shared-testsuite.yml +24 -0
- data/.gitmodules +3 -0
- data/.rspec-opal +3 -0
- data/Gemfile +18 -0
- data/README.adoc +297 -3
- data/docs/catalog.adoc +73 -0
- data/docs/uncertainty-asciimath-tree.md +92 -0
- data/docs/uncertainty.adoc +522 -0
- data/lib/plurimath/asciimath/parse.rb +43 -226
- data/lib/plurimath/asciimath/parser.rb +58 -1
- data/lib/plurimath/asciimath/slice_adapter.rb +138 -0
- data/lib/plurimath/asciimath/transform.rb +218 -0
- data/lib/plurimath/asciimath.rb +1 -0
- data/lib/plurimath/catalog/entry.rb +109 -0
- data/lib/plurimath/catalog/validation.rb +109 -0
- data/lib/plurimath/catalog.rb +96 -0
- data/lib/plurimath/cli/convert.rb +40 -0
- data/lib/plurimath/cli/helpers.rb +49 -0
- data/lib/plurimath/cli/render.rb +64 -0
- data/lib/plurimath/cli.rb +55 -57
- data/lib/plurimath/documentation.rb +99 -0
- data/lib/plurimath/errors/ambiguous_decimal_error.rb +19 -0
- data/lib/plurimath/errors/invalid_uncertainty.rb +18 -0
- data/lib/plurimath/errors/missing_dependency_error.rb +23 -0
- data/lib/plurimath/errors/rendering_failed.rb +17 -0
- data/lib/plurimath/errors/rendering_unavailable.rb +25 -0
- data/lib/plurimath/errors/unsupported_render_format.rb +18 -0
- data/lib/plurimath/errors/unsupported_semantic_tree_error.rb +12 -0
- data/lib/plurimath/errors.rb +5 -0
- data/lib/plurimath/math/evaluation/evaluator.rb +48 -0
- data/lib/plurimath/math/evaluation/expression_parser.rb +29 -20
- data/lib/plurimath/math/formula/class_tree.rb +92 -0
- data/lib/plurimath/math/formula.rb +43 -2
- data/lib/plurimath/math/function/abs.rb +6 -0
- data/lib/plurimath/math/function/arccos.rb +6 -0
- data/lib/plurimath/math/function/arcsin.rb +6 -0
- data/lib/plurimath/math/function/arctan.rb +6 -0
- data/lib/plurimath/math/function/arg.rb +20 -0
- data/lib/plurimath/math/function/bar.rb +6 -0
- data/lib/plurimath/math/function/base.rb +6 -0
- data/lib/plurimath/math/function/binary_function.rb +4 -0
- data/lib/plurimath/math/function/cancel.rb +6 -0
- data/lib/plurimath/math/function/ceil.rb +6 -0
- data/lib/plurimath/math/function/color.rb +12 -0
- data/lib/plurimath/math/function/cos.rb +6 -0
- data/lib/plurimath/math/function/cosh.rb +6 -0
- data/lib/plurimath/math/function/cot.rb +6 -0
- data/lib/plurimath/math/function/coth.rb +6 -0
- data/lib/plurimath/math/function/csc.rb +6 -0
- data/lib/plurimath/math/function/csch.rb +6 -0
- data/lib/plurimath/math/function/ddot.rb +6 -0
- data/lib/plurimath/math/function/deg.rb +6 -0
- data/lib/plurimath/math/function/det.rb +6 -0
- data/lib/plurimath/math/function/dim.rb +6 -0
- data/lib/plurimath/math/function/dot.rb +6 -0
- data/lib/plurimath/math/function/exp.rb +6 -0
- data/lib/plurimath/math/function/fenced.rb +6 -0
- data/lib/plurimath/math/function/floor.rb +6 -0
- data/lib/plurimath/math/function/font_style/bold-fraktur.rb +6 -0
- data/lib/plurimath/math/function/font_style/bold-italic.rb +6 -0
- data/lib/plurimath/math/function/font_style/bold-sans-serif.rb +6 -0
- data/lib/plurimath/math/function/font_style/bold-script.rb +6 -0
- data/lib/plurimath/math/function/font_style/bold.rb +6 -0
- data/lib/plurimath/math/function/font_style/double_struck.rb +6 -0
- data/lib/plurimath/math/function/font_style/fraktur.rb +6 -0
- data/lib/plurimath/math/function/font_style/italic.rb +6 -0
- data/lib/plurimath/math/function/font_style/monospace.rb +6 -0
- data/lib/plurimath/math/function/font_style/normal.rb +6 -0
- data/lib/plurimath/math/function/font_style/sans-serif-bold-italic.rb +6 -0
- data/lib/plurimath/math/function/font_style/sans-serif-italic.rb +6 -0
- data/lib/plurimath/math/function/font_style/sans-serif.rb +6 -0
- data/lib/plurimath/math/function/font_style/script.rb +6 -0
- data/lib/plurimath/math/function/font_style.rb +4 -0
- data/lib/plurimath/math/function/frac.rb +6 -0
- data/lib/plurimath/math/function/gcd.rb +6 -0
- data/lib/plurimath/math/function/glb.rb +12 -0
- data/lib/plurimath/math/function/hat.rb +6 -0
- data/lib/plurimath/math/function/hom.rb +6 -0
- data/lib/plurimath/math/function/inf.rb +6 -0
- data/lib/plurimath/math/function/int.rb +7 -1
- data/lib/plurimath/math/function/intent.rb +35 -0
- data/lib/plurimath/math/function/ker.rb +6 -0
- data/lib/plurimath/math/function/lcm.rb +12 -0
- data/lib/plurimath/math/function/lg.rb +6 -0
- data/lib/plurimath/math/function/lim.rb +6 -0
- data/lib/plurimath/math/function/liminf.rb +6 -0
- data/lib/plurimath/math/function/limits.rb +6 -0
- data/lib/plurimath/math/function/limsup.rb +6 -0
- data/lib/plurimath/math/function/ln.rb +6 -0
- data/lib/plurimath/math/function/log.rb +6 -0
- data/lib/plurimath/math/function/longdiv.rb +6 -0
- data/lib/plurimath/math/function/lub.rb +6 -0
- data/lib/plurimath/math/function/max.rb +6 -0
- data/lib/plurimath/math/function/mbox.rb +6 -0
- data/lib/plurimath/math/function/menclose.rb +19 -0
- data/lib/plurimath/math/function/merror.rb +22 -0
- data/lib/plurimath/math/function/min.rb +6 -0
- data/lib/plurimath/math/function/mlabeledtr.rb +23 -0
- data/lib/plurimath/math/function/mod.rb +6 -0
- data/lib/plurimath/math/function/msgroup.rb +20 -0
- data/lib/plurimath/math/function/multiscript.rb +6 -0
- data/lib/plurimath/math/function/nary.rb +11 -0
- data/lib/plurimath/math/function/norm.rb +6 -0
- data/lib/plurimath/math/function/obrace.rb +6 -0
- data/lib/plurimath/math/function/oint.rb +7 -1
- data/lib/plurimath/math/function/over.rb +6 -0
- data/lib/plurimath/math/function/overleftrightarrow.rb +6 -0
- data/lib/plurimath/math/function/overset.rb +6 -0
- data/lib/plurimath/math/function/phantom.rb +6 -0
- data/lib/plurimath/math/function/power.rb +6 -0
- data/lib/plurimath/math/function/power_base.rb +6 -0
- data/lib/plurimath/math/function/prod.rb +7 -1
- data/lib/plurimath/math/function/right.rb +1 -1
- data/lib/plurimath/math/function/root.rb +6 -0
- data/lib/plurimath/math/function/rule.rb +20 -0
- data/lib/plurimath/math/function/scarries.rb +20 -0
- data/lib/plurimath/math/function/sec.rb +6 -0
- data/lib/plurimath/math/function/sech.rb +6 -0
- data/lib/plurimath/math/function/semantics.rb +20 -0
- data/lib/plurimath/math/function/sin.rb +6 -0
- data/lib/plurimath/math/function/sinh.rb +6 -0
- data/lib/plurimath/math/function/sqrt.rb +6 -0
- data/lib/plurimath/math/function/stackrel.rb +6 -0
- data/lib/plurimath/math/function/substack.rb +6 -0
- data/lib/plurimath/math/function/sum.rb +7 -1
- data/lib/plurimath/math/function/sup.rb +6 -0
- data/lib/plurimath/math/function/table/align.rb +6 -0
- data/lib/plurimath/math/function/table/array.rb +6 -0
- data/lib/plurimath/math/function/table/bmatrix.rb +6 -0
- data/lib/plurimath/math/function/table/cases.rb +6 -0
- data/lib/plurimath/math/function/table/eqarray.rb +6 -0
- data/lib/plurimath/math/function/table/matrix.rb +6 -0
- data/lib/plurimath/math/function/table/multline.rb +6 -0
- data/lib/plurimath/math/function/table/pmatrix.rb +6 -0
- data/lib/plurimath/math/function/table/split.rb +6 -0
- data/lib/plurimath/math/function/table/vmatrix.rb +6 -0
- data/lib/plurimath/math/function/table.rb +10 -0
- data/lib/plurimath/math/function/tan.rb +6 -0
- data/lib/plurimath/math/function/tanh.rb +6 -0
- data/lib/plurimath/math/function/ternary_function.rb +4 -0
- data/lib/plurimath/math/function/text.rb +6 -0
- data/lib/plurimath/math/function/tilde.rb +6 -0
- data/lib/plurimath/math/function/ubrace.rb +6 -0
- data/lib/plurimath/math/function/ul.rb +6 -0
- data/lib/plurimath/math/function/unary_function.rb +4 -0
- data/lib/plurimath/math/function/uncertainty.rb +655 -0
- data/lib/plurimath/math/function/underover.rb +6 -0
- data/lib/plurimath/math/function/underset.rb +6 -0
- data/lib/plurimath/math/function/vec.rb +6 -0
- data/lib/plurimath/math/function.rb +1 -0
- data/lib/plurimath/math/renderer.rb +95 -0
- data/lib/plurimath/math/semantic_tree.rb +331 -0
- data/lib/plurimath/math/symbols/symbol.rb +2 -0
- data/lib/plurimath/math.rb +26 -0
- data/lib/plurimath/mathml/translator.rb +32 -0
- data/lib/plurimath/mathml/uncertainty_marker.rb +118 -0
- data/lib/plurimath/omml/translator.rb +68 -0
- data/lib/plurimath/omml/utility.rb +9 -1
- data/lib/plurimath/symbol_documentation.rb +57 -0
- data/lib/plurimath/unicode_math/parsing_rules/common_rules.rb +2 -1
- data/lib/plurimath/unicode_math/parsing_rules/masked.rb +3 -0
- data/lib/plurimath/unicode_math/transform.rb +64 -3
- data/lib/plurimath/unicode_math/utility.rb +38 -0
- data/lib/plurimath/version.rb +1 -1
- data/lib/plurimath.rb +3 -0
- data/plurimath.gemspec +3 -1
- data/scripts/replay_asciimath_corpus.rb +611 -0
- 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
|
data/lib/plurimath/asciimath.rb
CHANGED
|
@@ -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
|