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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +50 -0
- data/LICENSE.md +33 -0
- data/README.adoc +133 -14
- data/artifacts/latex.bindings.python.json +1 -0
- data/artifacts/latex.bindings.rs.json +1 -0
- data/artifacts/latex.bindings.ruby.json +1 -0
- data/artifacts/latex.bindings.ts.json +1 -0
- data/artifacts/latex.checksums.txt +19 -0
- data/artifacts/latex.json +1 -0
- data/data/latex-symbols.json +3301 -0
- data/docs/model-contract.adoc +125 -0
- data/docs/parser-contract.adoc +349 -0
- data/docs/testsuite-pin.adoc +76 -0
- data/grammars/latex.wrappers.json +17 -0
- data/lib/latexmath/model/nodes.rb +918 -0
- data/lib/latexmath/model.rb +44 -0
- data/lib/latexmath/parser/artifact.rb +80 -0
- data/lib/latexmath/parser/configuration.rb +35 -0
- data/lib/latexmath/parser/engine.rb +161 -0
- data/lib/latexmath/parser/errors.rb +51 -0
- data/lib/latexmath/parser/native_recursion_guard.rb +43 -0
- data/lib/latexmath/parser/preprocessor.rb +77 -0
- data/lib/latexmath/parser/raw_parser.rb +84 -0
- data/lib/latexmath/parser/source_map.rb +121 -0
- data/lib/latexmath/parser/unwrap.rb +91 -0
- data/lib/latexmath/parser.rb +97 -0
- data/lib/latexmath/symbols.rb +74 -0
- data/lib/latexmath/version.rb +1 -1
- data/lib/latexmath.rb +5 -87
- data/model/latex.lml.checksums.txt +36 -0
- data/model/latex.lml.json +3350 -0
- metadata +60 -71
- data/.editorconfig +0 -14
- data/.github/workflows/test.yml +0 -80
- data/.gitignore +0 -22
- data/.rspec +0 -3
- data/.rubocop.yml +0 -129
- data/CODE_OF_CONDUCT.md +0 -74
- data/Gemfile +0 -10
- data/Gemfile.lock +0 -79
- data/Rakefile +0 -95
- data/bin/console +0 -14
- data/bin/setup +0 -8
- data/exe/latexmath +0 -8
- data/latexmath.gemspec +0 -34
- data/lib/latexmath/aggregator.rb +0 -351
- data/lib/latexmath/converter.rb +0 -424
- data/lib/latexmath/equation.rb +0 -13
- data/lib/latexmath/ext.rb +0 -9
- data/lib/latexmath/symbol.rb +0 -21
- data/lib/latexmath/tokenizer.rb +0 -82
- data/lib/latexmath/xml/builder.rb +0 -4
- data/lib/latexmath/xml/element.rb +0 -25
- data/lib/unimathsymbols.js.erb +0 -4
- data/lib/unimathsymbols.txt +0 -2864
- data/opal/latexmath-opal.rb +0 -40
- data/opal/ox.rb +0 -64
- 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 & and " 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("&", "&")
|
|
72
|
+
.gsub(""", "\"")
|
|
73
|
+
.gsub(/(?<!\\\\)\\
/, "\\ ")
|
|
74
|
+
.gsub("
", "")
|
|
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
|