latexmath 0.1.4 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -0
  3. data/LICENSE.md +33 -0
  4. data/README.adoc +133 -14
  5. data/artifacts/latex.bindings.python.json +1 -0
  6. data/artifacts/latex.bindings.rs.json +1 -0
  7. data/artifacts/latex.bindings.ruby.json +1 -0
  8. data/artifacts/latex.bindings.ts.json +1 -0
  9. data/artifacts/latex.checksums.txt +19 -0
  10. data/artifacts/latex.json +1 -0
  11. data/data/latex-symbols.json +3301 -0
  12. data/docs/model-contract.adoc +125 -0
  13. data/docs/parser-contract.adoc +349 -0
  14. data/docs/testsuite-pin.adoc +76 -0
  15. data/grammars/latex.wrappers.json +17 -0
  16. data/lib/latexmath/model/nodes.rb +918 -0
  17. data/lib/latexmath/model.rb +44 -0
  18. data/lib/latexmath/parser/artifact.rb +80 -0
  19. data/lib/latexmath/parser/configuration.rb +35 -0
  20. data/lib/latexmath/parser/engine.rb +161 -0
  21. data/lib/latexmath/parser/errors.rb +51 -0
  22. data/lib/latexmath/parser/native_recursion_guard.rb +43 -0
  23. data/lib/latexmath/parser/preprocessor.rb +77 -0
  24. data/lib/latexmath/parser/raw_parser.rb +84 -0
  25. data/lib/latexmath/parser/source_map.rb +121 -0
  26. data/lib/latexmath/parser/unwrap.rb +91 -0
  27. data/lib/latexmath/parser.rb +97 -0
  28. data/lib/latexmath/symbols.rb +74 -0
  29. data/lib/latexmath/version.rb +1 -1
  30. data/lib/latexmath.rb +5 -87
  31. data/model/latex.lml.checksums.txt +36 -0
  32. data/model/latex.lml.json +3350 -0
  33. metadata +60 -71
  34. data/.editorconfig +0 -14
  35. data/.github/workflows/test.yml +0 -80
  36. data/.gitignore +0 -22
  37. data/.rspec +0 -3
  38. data/.rubocop.yml +0 -129
  39. data/CODE_OF_CONDUCT.md +0 -74
  40. data/Gemfile +0 -10
  41. data/Gemfile.lock +0 -79
  42. data/Rakefile +0 -95
  43. data/bin/console +0 -14
  44. data/bin/setup +0 -8
  45. data/exe/latexmath +0 -8
  46. data/latexmath.gemspec +0 -34
  47. data/lib/latexmath/aggregator.rb +0 -351
  48. data/lib/latexmath/converter.rb +0 -424
  49. data/lib/latexmath/equation.rb +0 -13
  50. data/lib/latexmath/ext.rb +0 -9
  51. data/lib/latexmath/symbol.rb +0 -21
  52. data/lib/latexmath/tokenizer.rb +0 -82
  53. data/lib/latexmath/xml/builder.rb +0 -4
  54. data/lib/latexmath/xml/element.rb +0 -25
  55. data/lib/unimathsymbols.js.erb +0 -4
  56. data/lib/unimathsymbols.txt +0 -2864
  57. data/opal/latexmath-opal.rb +0 -40
  58. data/opal/ox.rb +0 -64
  59. data/opal/pseudoenumerator.rb +0 -19
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+
5
+ module Latexmath
6
+ # The typed LaTeX language model, declared with LutaML Model.
7
+ #
8
+ # Latexmath.parse returns a Model::Document. Its classes
9
+ # (lib/latexmath/model/nodes.rb) are generated by scripts/generate_model.rb
10
+ # from the capture shapes of the normalized grammar: every capture holds
11
+ # one kind, so each class has one attribute per capture key and
12
+ # lutaml-model builds the whole tree from the declared attribute types
13
+ # (Document.new on the grammar's model binding). No code here walks the
14
+ # parse tree. See docs/model-contract.adoc.
15
+ module Model
16
+ end
17
+ end
18
+
19
+ require_relative "model/nodes"
20
+
21
+ module Latexmath
22
+ module Model
23
+ # The symbol table entry of a symbol is looked up from its spelling,
24
+ # stripped, as in the legacy transform (data/latex-symbols.json, read by
25
+ # Latexmath::Symbols). A spelling the table lacks is a plain symbol of
26
+ # its own text, kind "symbol".
27
+ class MathSymbol
28
+ # The Symbols::Entry for #spelling.
29
+ def entry
30
+ Latexmath::Symbols.resolve(spelling.to_s)
31
+ end
32
+
33
+ # The Unicode text the symbol stands for.
34
+ def value
35
+ entry.value
36
+ end
37
+
38
+ # "symbol", "open" or "close".
39
+ def kind
40
+ entry.kind
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Latexmath
6
+ # The compiled grammar and its declared Ruby bindings, loaded, verified
7
+ # and paired once per pair of paths.
8
+ class Artifact
9
+ Loaded = Struct.new(:parse, :bindings)
10
+
11
+ # Loads the Parsanol::PARG::Artifact at +path+ and checks it has an
12
+ # entry for every decimal marker.
13
+ def self.load_parse(path)
14
+ raise ArtifactError, "artifact not found: #{path}" unless File.file?(path)
15
+
16
+ artifact = Parsanol::PARG::Artifact.load(path)
17
+ missing = Configuration::DECIMAL_ENTRIES.values - artifact.entries
18
+ unless missing.empty?
19
+ raise ArtifactError, "artifact #{path} lacks required entries: #{missing.join(', ')}"
20
+ end
21
+
22
+ artifact
23
+ rescue JSON::ParserError => e
24
+ raise ArtifactError, "artifact #{path} is not valid JSON: #{e.message.lines.first&.strip}"
25
+ rescue Parsanol::PARG::Error, SystemCallError, KeyError, TypeError, NoMethodError => e
26
+ raise ArtifactError, "artifact #{path} is invalid: #{e.class}: #{e.message}"
27
+ end
28
+
29
+ # Loads the Ruby bindings artifact at +path+: kind, checksum, target and
30
+ # binding version are verified, then it is paired with +parse+ (same
31
+ # grammar, version and source hash). Every entry must declare the tree
32
+ # capture.
33
+ def self.load_bindings(path, parse)
34
+ raise ArtifactError, "bindings artifact not found: #{path}" unless File.file?(path)
35
+
36
+ bindings = Parsanol::PARG::Bindings::Artifact.from_json(File.read(path, encoding: "UTF-8"),
37
+ target: "ruby")
38
+ bindings.pair_with!(parse)
39
+ Configuration::DECIMAL_ENTRIES.each_value do |entry|
40
+ declared = bindings.bindings(entry).map { |b| b["capture"] }
41
+ next if declared.include?(Configuration::TREE_CAPTURE.to_s)
42
+
43
+ raise ArtifactError, "bindings artifact #{path} does not bind #{Configuration::TREE_CAPTURE} for entry #{entry}"
44
+ end
45
+ bindings
46
+ rescue JSON::ParserError => e
47
+ raise ArtifactError, "bindings artifact #{path} is not valid JSON: #{e.message.lines.first&.strip}"
48
+ rescue Parsanol::PARG::Error, SystemCallError, KeyError, TypeError, NoMethodError => e
49
+ raise ArtifactError, "bindings artifact #{path} is invalid: #{e.class}: #{e.message}"
50
+ end
51
+
52
+ attr_reader :path, :bindings_path
53
+
54
+ def initialize(path, bindings_path)
55
+ @path = path
56
+ @bindings_path = bindings_path
57
+ @loaded = nil
58
+ @lock = Mutex.new
59
+ end
60
+
61
+ # Points at other artifacts (mainly for tests); the next #fetch loads them.
62
+ def use(path, bindings_path)
63
+ @lock.synchronize do
64
+ @path = path.to_s
65
+ @bindings_path = bindings_path.to_s
66
+ @loaded = nil
67
+ end
68
+ end
69
+
70
+ # The loaded pair, loaded on first use.
71
+ def fetch
72
+ @lock.synchronize do
73
+ @loaded ||= begin
74
+ parse = self.class.load_parse(@path)
75
+ Loaded.new(parse, self.class.load_bindings(@bindings_path, parse))
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Latexmath
4
+ # Fixed settings: the artifact entries, the engine names and where the
5
+ # bundled artifacts live.
6
+ module Configuration
7
+ # Decimal marker => artifact entry name.
8
+ DECIMAL_ENTRIES = {
9
+ "." => "dot",
10
+ "," => "comma",
11
+ "٫" => "arabic", # ARABIC DECIMAL SEPARATOR
12
+ }.freeze
13
+
14
+ ENGINES = %i[auto native ruby].freeze
15
+
16
+ ARTIFACT_DIR = File.expand_path("../../../artifacts", __dir__)
17
+ DEFAULT_ARTIFACT_PATH = File.join(ARTIFACT_DIR, "latex.json")
18
+ DEFAULT_BINDINGS_PATH = File.join(ARTIFACT_DIR, "latex.bindings.ruby.json")
19
+
20
+ # The capture under which the declared bindings carry the legacy tree.
21
+ TREE_CAPTURE = :latex_tree
22
+ # The binding target the typed model is built from.
23
+ MODEL_CAPTURE = :model
24
+
25
+ # Capture names the PARG grammar spells differently from the legacy
26
+ # grammar, and the key the legacy raw tree has instead. The legacy
27
+ # grammar captures a matrix row break as :"\\\\" and then renames that
28
+ # key to the String "\\\\"; PARG names must be identifiers.
29
+ CAPTURE_RENAMES = { row_break: "\\\\" }.freeze
30
+
31
+ # Deepest bracket nesting sent to the native engine off the main thread,
32
+ # where the machine stack is small.
33
+ THREAD_NATIVE_DEPTH = 25
34
+ end
35
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Latexmath
4
+ # Runs the grammar on the native or Ruby Parsanol engine with the legacy
5
+ # grammar's settings (Parsanol's default, strict memo policy), maps
6
+ # failures to Latexmath errors and records the engine that produced the
7
+ # result per thread.
8
+ #
9
+ # Internal.
10
+ module Engine
11
+ LAST_ENGINE_KEY = :latexmath_last_engine
12
+ OPENING = "([{".chars.freeze
13
+ CLOSING = ")]}".chars.freeze
14
+
15
+ module_function
16
+
17
+ def last_engine
18
+ Thread.current[LAST_ENGINE_KEY]
19
+ end
20
+
21
+ def native_available?
22
+ Parsanol::Native.available?
23
+ rescue LoadError, StandardError
24
+ false
25
+ end
26
+
27
+ # The engine (:native or :ruby) that +engine+ (one of ENGINES) runs.
28
+ def resolve(engine)
29
+ case engine
30
+ when :auto then native_available? ? :native : :ruby
31
+ when :ruby then :ruby
32
+ when :native
33
+ return :native if native_available?
34
+
35
+ raise EngineUnavailable,
36
+ "engine: :native requested but the Parsanol native extension " \
37
+ "is not available on this platform (#{RUBY_PLATFORM}, Ruby #{RUBY_VERSION})"
38
+ else
39
+ raise ArgumentError,
40
+ "unknown engine #{engine.inspect} (use #{Configuration::ENGINES.map(&:inspect).join(', ')})"
41
+ end
42
+ end
43
+
44
+ # Parses +text+ from +entry+ of the parse artifact +artifact+ on +mode+
45
+ # and returns the parse shape. Under +fallback+ (engine: :auto) input
46
+ # too deep for the native engine is rerun on Ruby. +source+ is the
47
+ # normalized caller input +text+ was preprocessed from; a ParseError
48
+ # reports its position in +source+ (and in +text+).
49
+ def run(artifact, entry, text, mode, fallback:, source:)
50
+ shape, ran = run_with_fallback(artifact, entry, text, mode, fallback: fallback)
51
+ record(ran)
52
+ shape
53
+ rescue ParseError => e
54
+ raise in_source(e, source)
55
+ end
56
+
57
+ # +error+ (positioned in the grammar input) as a ParseError of the same
58
+ # class positioned in +source+.
59
+ def in_source(error, source)
60
+ text = error.input
61
+ char = error.char_offset && SourceMap.caller_char(source, text, error.char_offset)
62
+ byte = char && source[0, char].bytesize
63
+ message = error.message
64
+ if error.byte_offset
65
+ where = char ? "at character #{char} (byte #{byte})" : "at an unmapped position"
66
+ message = message.sub("LaTeX input rejected at", "LaTeX input rejected #{where}; preprocessed text")
67
+ end
68
+ error.class.new(message, input: source, byte_offset: byte, char_offset: char,
69
+ grammar_input: text, grammar_byte_offset: error.byte_offset,
70
+ grammar_char_offset: error.char_offset)
71
+ end
72
+
73
+ def run_with_fallback(artifact, entry, text, mode, fallback:)
74
+ if mode == :native && too_deep_for_native_thread?(text)
75
+ record(:native)
76
+ unless fallback
77
+ raise native_too_deep(text, "nesting depth over #{Configuration::THREAD_NATIVE_DEPTH} off the main thread")
78
+ end
79
+
80
+ return [run_mode(artifact, entry, text, :ruby), :ruby]
81
+ end
82
+
83
+ [run_mode(artifact, entry, text, mode), mode]
84
+ rescue NativeRecursionLimit => e
85
+ record(:native)
86
+ raise native_too_deep(text, e.message) unless fallback
87
+
88
+ [run_mode(artifact, entry, text, :ruby), :ruby]
89
+ rescue NestingTooDeep
90
+ raise unless fallback && mode == :native
91
+
92
+ [run_mode(artifact, entry, text, :ruby), :ruby]
93
+ end
94
+
95
+ def too_deep_for_native_thread?(text)
96
+ return false if Thread.current.equal?(Thread.main)
97
+
98
+ depth = 0
99
+ max = 0
100
+ text.each_char do |char|
101
+ if OPENING.include?(char)
102
+ depth += 1
103
+ max = depth if depth > max
104
+ elsif CLOSING.include?(char)
105
+ depth -= 1 if depth.positive?
106
+ end
107
+ end
108
+ max > Configuration::THREAD_NATIVE_DEPTH
109
+ end
110
+
111
+ def native_too_deep(text, detail)
112
+ NestingTooDeep.new(
113
+ "LaTeX input nests deeper than the native engine's recursion limit " \
114
+ "(#{detail}); use engine: :ruby or engine: :auto",
115
+ input: text, byte_offset: nil, char_offset: nil,
116
+ )
117
+ end
118
+
119
+ def run_mode(artifact, entry, text, mode)
120
+ root = artifact.root_atom(entry)
121
+ reporter = nil
122
+ if mode == :native
123
+ NativeRecursionGuard.watch { Parsanol::Native.parse(root, text) }
124
+ else
125
+ reporter = Parsanol::ErrorReporter::Deepest.new
126
+ root.parse(text, reporter: reporter)
127
+ end
128
+ rescue Parsanol::ParseFailed => e
129
+ record(mode)
130
+ raise parse_error(e, reporter, text)
131
+ rescue SystemStackError
132
+ record(mode)
133
+ raise NestingTooDeep.new(
134
+ "LaTeX input nests too deeply for the #{mode} engine (Ruby stack exhausted)",
135
+ input: text, byte_offset: nil, char_offset: nil,
136
+ )
137
+ rescue Parsanol::PARG::Error, Parsanol::GrammarError, Parsanol::Native::UnsupportedGrammar => e
138
+ raise ArtifactError, "the grammar artifact cannot be used: #{e.class}: #{e.message}"
139
+ end
140
+
141
+ # The legacy grammar runs on the native engine, whose failure reports
142
+ # the furthest position any branch reached. The Ruby engine's raised
143
+ # cause is the root failure; its Deepest reporter holds the furthest
144
+ # leaf, which is the same position.
145
+ def parse_error(error, reporter, text)
146
+ cause = reporter&.deepest_cause || error.parse_failure_cause
147
+ byte = cause&.position
148
+ char = byte && text.byteslice(0, byte).force_encoding(Encoding::UTF_8).scrub.length
149
+ where = byte ? " at character #{char} (byte #{byte})" : ""
150
+ ParseError.new("LaTeX input rejected#{where}: #{error.message.lines.first.to_s.strip[0, 200]}",
151
+ input: text, byte_offset: byte, char_offset: char)
152
+ end
153
+
154
+ def record(mode)
155
+ Thread.current[LAST_ENGINE_KEY] = mode
156
+ end
157
+
158
+ private_class_method :in_source, :run_with_fallback, :native_too_deep, :run_mode, :parse_error,
159
+ :record, :too_deep_for_native_thread?
160
+ end
161
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Latexmath
4
+ # Base class for every error Latexmath raises on purpose, except
5
+ # ArgumentError for invalid arguments.
6
+ class Error < StandardError; end
7
+
8
+ # The grammar refused the input.
9
+ #
10
+ # +input+ is the caller's input, as normalized UTF-8, and the offsets are
11
+ # zero-based positions in it: +byte_offset+ in UTF-8 bytes, +char_offset+
12
+ # in characters. They are the position the legacy Plurimath grammar
13
+ # reports, mapped back through the preprocessing (see SourceMap): an
14
+ # entity or other text the preprocessing wrote maps to the start of the
15
+ # caller text it stands for.
16
+ #
17
+ # +grammar_input+ is the text the grammar ran on (the preprocessed text);
18
+ # +grammar_byte_offset+ and +grammar_char_offset+ are the same position in
19
+ # it, as the legacy grammar reports it on either engine. They are for
20
+ # parity replay and diagnostics.
21
+ class ParseError < Error
22
+ attr_reader :input, :byte_offset, :char_offset,
23
+ :grammar_input, :grammar_byte_offset, :grammar_char_offset
24
+
25
+ def initialize(message, input:, byte_offset:, char_offset:,
26
+ grammar_input: nil, grammar_byte_offset: nil, grammar_char_offset: nil)
27
+ super(message)
28
+ @input = input
29
+ @byte_offset = byte_offset
30
+ @char_offset = char_offset
31
+ @grammar_input = grammar_input
32
+ @grammar_byte_offset = grammar_byte_offset
33
+ @grammar_char_offset = grammar_char_offset
34
+ end
35
+ end
36
+
37
+ # The engine that ran cannot handle the input's nesting depth: the native
38
+ # engine's recursion limit, or an exhausted Ruby stack. +input+ and
39
+ # +grammar_input+ are set; every offset is nil. This does not claim that
40
+ # the input is invalid.
41
+ class NestingTooDeep < ParseError; end
42
+
43
+ # A parse or bindings artifact is missing, malformed, fails its checksum,
44
+ # declares the wrong target or kind, or does not pair with the other.
45
+ # Latexmath never falls back to another artifact.
46
+ class ArtifactError < Error; end
47
+
48
+ # engine: :native was requested but Parsanol's native extension is not
49
+ # available on this platform.
50
+ class EngineUnavailable < Error; end
51
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Latexmath
4
+ # Raised inside Parsanol's native error path when the native engine hit
5
+ # its recursion limit, before Parsanol's interpreter reparse runs (that
6
+ # reparse can take minutes or exhaust the Ruby stack on deep input).
7
+ class NativeRecursionLimit < Error; end
8
+ private_constant :NativeRecursionLimit
9
+
10
+ # Parsanol::Native.raise_native_parse_error translates a native failure.
11
+ # While Latexmath's own native call runs on this thread, this hook turns a
12
+ # recursion-limit failure into NativeRecursionLimit; every other failure,
13
+ # and every other caller of Parsanol, is unaffected. (The same guard as
14
+ # amath's.)
15
+ module NativeRecursionGuard
16
+ MESSAGE = /\A(?:Maximum recursion depth \(\d+\) exceeded|Recursion limit exceeded\b)/
17
+ KEY = :latexmath_native_recursion_guard
18
+
19
+ def self.watch
20
+ outer = Thread.current[KEY]
21
+ Thread.current[KEY] = true
22
+ yield
23
+ ensure
24
+ Thread.current[KEY] = outer
25
+ end
26
+
27
+ def self.installed?
28
+ Parsanol::Native.singleton_class.ancestors.include?(self)
29
+ end
30
+
31
+ def raise_native_parse_error(error, grammar, input)
32
+ if Thread.current[KEY] && error.message.match?(MESSAGE)
33
+ raise NativeRecursionLimit, error.message.lines.first.strip, cause: nil
34
+ end
35
+
36
+ super
37
+ end
38
+ end
39
+
40
+ if Parsanol::Native.singleton_class.method_defined?(:raise_native_parse_error)
41
+ Parsanol::Native.singleton_class.prepend(NativeRecursionGuard)
42
+ end
43
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "htmlentities"
4
+
5
+ module Latexmath
6
+ # The legacy LaTeX input preprocessing (Plurimath::Latex::Parser at the
7
+ # pinned commit, see docs/parser-contract.adoc), step for step:
8
+ #
9
+ # 1. remember every \mbox{...} and \text{...} group of the input;
10
+ # 2. decode HTML entities, then encode the whole text with hexadecimal
11
+ # entities;
12
+ # 3. remove spaces that do not follow a backslash and runs of newlines;
13
+ # turn "\\ " into "\\"; turn &#x26; and &#x22; back into & and ";
14
+ # turn a backslash before an encoded newline into "\ " unless it ends
15
+ # a row break; drop the remaining encoded newlines;
16
+ # 4. put the remembered groups back as the caller wrote them.
17
+ #
18
+ # Internal.
19
+ module Preprocessor
20
+ TEXT_OPEN = /\\(?:mbox|text)\{/
21
+ TEXT_OPEN_LENGTH = "\\mbox{".length
22
+ ENTITIES = HTMLEntities.new
23
+
24
+ module_function
25
+
26
+ # The grammar input for the normalized UTF-8 caller +input+.
27
+ def call(input)
28
+ text_functions = text_groups(input).map { |range| input[range] }
29
+ text = ENTITIES.encode(ENTITIES.decode(input), :hexadecimal)
30
+ text = gsub_space_and_unicodes(text)
31
+ replace_text_groups(text) { text_functions.shift }
32
+ end
33
+
34
+ # Legacy: the matches of %r(\\(?:mbox|text)\{[^}]+\}), as index ranges.
35
+ # Each match runs from an opener to the first "}" after it, with at
36
+ # least one character between; an opener followed directly by "}" does
37
+ # not match there and the search goes on one character later. Written
38
+ # out without the backtracking regexp, which is polynomial on input
39
+ # with many unclosed openers.
40
+ def text_groups(text)
41
+ groups = []
42
+ pos = 0
43
+ while (start = text.index(TEXT_OPEN, pos))
44
+ body = start + TEXT_OPEN_LENGTH
45
+ close = text.index("}", body) or break
46
+ if close == body
47
+ pos = start + 1
48
+ else
49
+ groups << (start..close)
50
+ pos = close + 1
51
+ end
52
+ end
53
+ groups
54
+ end
55
+
56
+ # Legacy: text.gsub(TEXT_REGEX) { ... }, keeping its result encoding.
57
+ def replace_text_groups(text)
58
+ out = String.new(encoding: text.encoding)
59
+ pos = 0
60
+ text_groups(text).each do |range|
61
+ out << text[pos...range.begin] << yield.to_s
62
+ pos = range.end + 1
63
+ end
64
+ out << text[pos..]
65
+ end
66
+
67
+ def gsub_space_and_unicodes(text)
68
+ text
69
+ .gsub(/((?<!\\) )|\n+/, "")
70
+ .gsub("\\\\ ", "\\\\\\\\")
71
+ .gsub("&#x26;", "&")
72
+ .gsub("&#x22;", "\"")
73
+ .gsub(/(?<!\\\\)\\&#xa;/, "\\ ")
74
+ .gsub("&#xa;", "")
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Latexmath
4
+ # The internal raw-tree boundary: the legacy preprocessing, then the
5
+ # grammar, then the declared syntax bindings, which carry the legacy tree.
6
+ #
7
+ # Internal and for tests and parity replay only; not public API. The
8
+ # public Latexmath.parse, which returns the typed LaTeX model, is built on
9
+ # top of it.
10
+ module RawParser
11
+ # +input+: the normalized UTF-8 caller input; +text+: the grammar input
12
+ # (Preprocessor.call); +tree+: the legacy raw tree (Hashes, Arrays and
13
+ # Parsanol::Slice leaves, with the row-break key restored), which is
14
+ # Unwrap of +raw+; +raw+: the grammar's own tree, with the wrappers its
15
+ # shape normalization adds (grammars/latex.wrappers.json); +model+: the
16
+ # same tree as the model binding gives it, the attributes the typed
17
+ # model is built from; +engine+: the engine that produced it.
18
+ Result = Struct.new(:input, :text, :raw, :model, :engine, keyword_init: true) do
19
+ def tree
20
+ @tree ||= RawParser.restore_capture_names(Unwrap.call(raw))
21
+ end
22
+ end
23
+
24
+ module_function
25
+
26
+ def parse(input, decimal: ".", engine: :auto)
27
+ entry = entry_for(decimal)
28
+ mode = Engine.resolve(engine)
29
+ normalized = normalize_input(input)
30
+ text = Preprocessor.call(normalized)
31
+
32
+ loaded = Latexmath.artifacts
33
+ shape = Engine.run(loaded.parse, entry, grammar_input(text), mode,
34
+ fallback: engine == :auto, source: normalized)
35
+ bound = loaded.bindings.apply(entry, shape)
36
+ raw, model = [Configuration::TREE_CAPTURE, Configuration::MODEL_CAPTURE].map do |name|
37
+ bound.fetch(name) { raise ArtifactError, "the declared bindings did not produce #{name}" }
38
+ end
39
+ Result.new(input: normalized, text: text, raw: raw, model: model, engine: Engine.last_engine)
40
+ end
41
+
42
+ def entry_for(decimal)
43
+ Configuration::DECIMAL_ENTRIES.fetch(decimal) do
44
+ raise ArgumentError,
45
+ "unsupported decimal marker #{decimal.inspect} " \
46
+ "(supported: #{Configuration::DECIMAL_ENTRIES.keys.map(&:inspect).join(', ')})"
47
+ end
48
+ end
49
+
50
+ # The preprocessed text as UTF-8. Preprocessing returns US-ASCII text
51
+ # when nothing outside \text and \mbox groups is non-ASCII (as
52
+ # the legacy preprocessing does); the native engine gives UTF-8 slices
53
+ # either way, and the Ruby engine's slices take the input's encoding.
54
+ def grammar_input(text)
55
+ text.encoding == Encoding::UTF_8 ? text : text.dup.force_encoding(Encoding::UTF_8)
56
+ end
57
+
58
+ # +input+ as valid UTF-8, or an ArgumentError.
59
+ def normalize_input(input)
60
+ raise ArgumentError, "input must be a String, got #{input.class}" unless input.is_a?(String)
61
+
62
+ text = input.encoding == Encoding::UTF_8 ? input : input.encode(Encoding::UTF_8)
63
+ raise ArgumentError, "input is not valid UTF-8" unless text.valid_encoding?
64
+
65
+ text
66
+ rescue EncodingError => e
67
+ raise ArgumentError, "input cannot be converted to UTF-8: #{e.message}"
68
+ end
69
+
70
+ # Renames the captures the grammar had to spell differently
71
+ # (Configuration::CAPTURE_RENAMES) back to their legacy keys, keeping
72
+ # key order.
73
+ def restore_capture_names(tree)
74
+ case tree
75
+ when Hash
76
+ tree.to_h do |key, value|
77
+ [Configuration::CAPTURE_RENAMES.fetch(key, key), restore_capture_names(value)]
78
+ end
79
+ when Array then tree.map { |value| restore_capture_names(value) }
80
+ else tree
81
+ end
82
+ end
83
+ end
84
+ end