mathpix 0.1.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 +7 -0
- data/CHANGELOG.md +52 -0
- data/LICENSE +21 -0
- data/README.md +171 -0
- data/SECURITY.md +137 -0
- data/lib/mathpix/balanced_ternary.rb +86 -0
- data/lib/mathpix/batch.rb +155 -0
- data/lib/mathpix/capture_builder.rb +142 -0
- data/lib/mathpix/chemistry.rb +69 -0
- data/lib/mathpix/client.rb +439 -0
- data/lib/mathpix/configuration.rb +187 -0
- data/lib/mathpix/configuration.rb.backup +125 -0
- data/lib/mathpix/conversion.rb +257 -0
- data/lib/mathpix/document.rb +320 -0
- data/lib/mathpix/errors.rb +78 -0
- data/lib/mathpix/mcp/auth/oauth_provider.rb +346 -0
- data/lib/mathpix/mcp/auth/token_manager.rb +31 -0
- data/lib/mathpix/mcp/auth.rb +18 -0
- data/lib/mathpix/mcp/base_tool.rb +117 -0
- data/lib/mathpix/mcp/elicitations/ambiguity_elicitation.rb +162 -0
- data/lib/mathpix/mcp/elicitations/base_elicitation.rb +141 -0
- data/lib/mathpix/mcp/elicitations/confidence_elicitation.rb +162 -0
- data/lib/mathpix/mcp/elicitations.rb +78 -0
- data/lib/mathpix/mcp/middleware/cors_middleware.rb +94 -0
- data/lib/mathpix/mcp/middleware/oauth_middleware.rb +72 -0
- data/lib/mathpix/mcp/middleware/rate_limiting_middleware.rb +140 -0
- data/lib/mathpix/mcp/middleware.rb +13 -0
- data/lib/mathpix/mcp/resources/formats_list_resource.rb +113 -0
- data/lib/mathpix/mcp/resources/hierarchical_router.rb +237 -0
- data/lib/mathpix/mcp/resources/latest_snip_resource.rb +60 -0
- data/lib/mathpix/mcp/resources/recent_snips_resource.rb +75 -0
- data/lib/mathpix/mcp/resources/snip_stats_resource.rb +78 -0
- data/lib/mathpix/mcp/resources.rb +15 -0
- data/lib/mathpix/mcp/server.rb +174 -0
- data/lib/mathpix/mcp/tools/batch_convert_tool.rb +106 -0
- data/lib/mathpix/mcp/tools/check_document_status_tool.rb +66 -0
- data/lib/mathpix/mcp/tools/convert_document_tool.rb +90 -0
- data/lib/mathpix/mcp/tools/convert_image_tool.rb +91 -0
- data/lib/mathpix/mcp/tools/convert_strokes_tool.rb +82 -0
- data/lib/mathpix/mcp/tools/get_account_info_tool.rb +57 -0
- data/lib/mathpix/mcp/tools/get_usage_tool.rb +62 -0
- data/lib/mathpix/mcp/tools/list_formats_tool.rb +81 -0
- data/lib/mathpix/mcp/tools/search_results_tool.rb +111 -0
- data/lib/mathpix/mcp/transports/http_streaming_transport.rb +622 -0
- data/lib/mathpix/mcp/transports/sse_stream_handler.rb +236 -0
- data/lib/mathpix/mcp/transports.rb +12 -0
- data/lib/mathpix/mcp.rb +52 -0
- data/lib/mathpix/result.rb +364 -0
- data/lib/mathpix/version.rb +22 -0
- data/lib/mathpix.rb +229 -0
- metadata +283 -0
@@ -0,0 +1,236 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'securerandom'
|
4
|
+
require 'json'
|
5
|
+
require 'thread' # For Queue
|
6
|
+
|
7
|
+
module Mathpix
|
8
|
+
module MCP
|
9
|
+
module Transports
|
10
|
+
# Handles Server-Sent Events (SSE) streaming for MCP protocol
|
11
|
+
class SSEStreamHandler
|
12
|
+
attr_reader :mcp_server, :session_id
|
13
|
+
attr_accessor :stop_flag
|
14
|
+
|
15
|
+
HEARTBEAT_INTERVAL = 10 # seconds
|
16
|
+
TEST_HEARTBEAT_INTERVAL = 0.1 # 100ms for tests (fast disconnect detection)
|
17
|
+
TIMEOUT = 300 # 5 minutes
|
18
|
+
TEST_TIMEOUT = 30 # 30 seconds for tests
|
19
|
+
EVENT_LOOP_CHECK_INTERVAL = 0.05 # 50ms for production
|
20
|
+
TEST_EVENT_LOOP_CHECK_INTERVAL = 0.005 # 5ms for tests (fast stop flag response)
|
21
|
+
|
22
|
+
def initialize(mcp_server, env, test_mode: false, transport: nil)
|
23
|
+
@mcp_server = mcp_server
|
24
|
+
@transport = transport # Transport for connection tracking
|
25
|
+
@session_id = extract_session_id(env)
|
26
|
+
@event_id = 0
|
27
|
+
@last_activity = Time.now
|
28
|
+
@heartbeat_thread = nil
|
29
|
+
@test_mode = test_mode
|
30
|
+
@stop_flag = false
|
31
|
+
@event_queue = Queue.new # Thread-safe event queue
|
32
|
+
end
|
33
|
+
|
34
|
+
def handle
|
35
|
+
headers = {
|
36
|
+
'Content-Type' => 'text/event-stream',
|
37
|
+
'Cache-Control' => 'no-cache',
|
38
|
+
'Connection' => 'keep-alive',
|
39
|
+
'X-Accel-Buffering' => 'no' # Disable nginx buffering
|
40
|
+
}
|
41
|
+
|
42
|
+
stream = proc do |out|
|
43
|
+
@stream = out # Store stream for external access
|
44
|
+
@closed = false
|
45
|
+
register_connection
|
46
|
+
|
47
|
+
begin
|
48
|
+
# Send connection event
|
49
|
+
send_connection_event(out)
|
50
|
+
|
51
|
+
# Start heartbeat
|
52
|
+
start_heartbeat(out)
|
53
|
+
|
54
|
+
# Event loop: process queued events until timeout or close
|
55
|
+
timeout = @test_mode ? TEST_TIMEOUT : TIMEOUT
|
56
|
+
check_interval = @test_mode ? TEST_EVENT_LOOP_CHECK_INTERVAL : EVENT_LOOP_CHECK_INTERVAL
|
57
|
+
start_time = Time.now
|
58
|
+
$stderr.puts "[SSE] Event loop started (timeout: #{timeout}s, check_interval: #{check_interval * 1000}ms)"
|
59
|
+
|
60
|
+
loop do
|
61
|
+
# Check for timeout
|
62
|
+
if Time.now - start_time > timeout
|
63
|
+
$stderr.puts "[SSE] Timeout reached, sending timeout event"
|
64
|
+
send_event(out, 'timeout', {
|
65
|
+
message: 'Connection timed out due to inactivity'
|
66
|
+
})
|
67
|
+
break
|
68
|
+
end
|
69
|
+
|
70
|
+
# Check for stop signal
|
71
|
+
if @stop_flag
|
72
|
+
$stderr.puts "[SSE] Stop flag set, exiting loop"
|
73
|
+
break
|
74
|
+
end
|
75
|
+
|
76
|
+
# Process queued events (non-blocking with short timeout)
|
77
|
+
begin
|
78
|
+
event = @event_queue.pop(true) # non_block = true
|
79
|
+
$stderr.puts "[SSE] Sending queued event: #{event[:type]}"
|
80
|
+
send_event(out, event[:type], event[:data])
|
81
|
+
rescue ThreadError
|
82
|
+
# Queue empty - that's ok, sleep briefly and check again
|
83
|
+
sleep check_interval
|
84
|
+
end
|
85
|
+
end
|
86
|
+
$stderr.puts "[SSE] Event loop exited"
|
87
|
+
rescue IOError, Errno::EPIPE, Errno::ECONNRESET => e
|
88
|
+
# Connection closed by client - exit cleanly
|
89
|
+
@closed = true
|
90
|
+
rescue StandardError => e
|
91
|
+
# Unexpected error - log and exit
|
92
|
+
@closed = true
|
93
|
+
ensure
|
94
|
+
stop_heartbeat
|
95
|
+
unregister_connection
|
96
|
+
@stream = nil
|
97
|
+
@closed = true
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
[200, headers, stream]
|
102
|
+
end
|
103
|
+
|
104
|
+
# Public methods for sending events (used by transport layer)
|
105
|
+
# Events are queued and processed by the event loop
|
106
|
+
def send_tool_result_event(request_id, tool_response)
|
107
|
+
return if @closed
|
108
|
+
|
109
|
+
content = tool_response.content.first
|
110
|
+
result = JSON.parse(content[:text])
|
111
|
+
|
112
|
+
@event_queue << {
|
113
|
+
type: 'tool_result',
|
114
|
+
data: {
|
115
|
+
request_id: request_id,
|
116
|
+
success: !tool_response.is_error,
|
117
|
+
result: result
|
118
|
+
}
|
119
|
+
}
|
120
|
+
end
|
121
|
+
|
122
|
+
def send_tool_error_event(request_id, error)
|
123
|
+
return if @closed
|
124
|
+
|
125
|
+
@event_queue << {
|
126
|
+
type: 'tool_error',
|
127
|
+
data: {
|
128
|
+
request_id: request_id,
|
129
|
+
error: 'tool_execution_error',
|
130
|
+
error_description: error.message
|
131
|
+
}
|
132
|
+
}
|
133
|
+
end
|
134
|
+
|
135
|
+
def send_custom_event(event_type, data)
|
136
|
+
return if @closed
|
137
|
+
|
138
|
+
$stderr.puts "[SSE] Queueing event: #{event_type}"
|
139
|
+
@event_queue << {
|
140
|
+
type: event_type,
|
141
|
+
data: data
|
142
|
+
}
|
143
|
+
$stderr.puts "[SSE] Event queued, queue size: #{@event_queue.size}"
|
144
|
+
end
|
145
|
+
|
146
|
+
def close
|
147
|
+
@closed = true
|
148
|
+
@stop_flag = true
|
149
|
+
@stream&.close rescue nil
|
150
|
+
end
|
151
|
+
|
152
|
+
private
|
153
|
+
|
154
|
+
def extract_session_id(env)
|
155
|
+
env.dig('rack.session', :session_id) || SecureRandom.uuid
|
156
|
+
end
|
157
|
+
|
158
|
+
def send_event(stream, event_type, data)
|
159
|
+
return if @closed # Don't send if already closed
|
160
|
+
|
161
|
+
@event_id += 1
|
162
|
+
|
163
|
+
stream.write("event: #{event_type}\n")
|
164
|
+
stream.write("id: #{@event_id}\n")
|
165
|
+
stream.write("data: #{JSON.generate(data)}\n")
|
166
|
+
stream.write("\n")
|
167
|
+
|
168
|
+
@last_activity = Time.now
|
169
|
+
rescue IOError, Errno::EPIPE, Errno::ECONNRESET
|
170
|
+
# Connection closed by client
|
171
|
+
@closed = true
|
172
|
+
nil # Return nil instead of raising
|
173
|
+
end
|
174
|
+
|
175
|
+
def send_connection_event(stream)
|
176
|
+
send_event(stream, 'connection', {
|
177
|
+
session_id: session_id,
|
178
|
+
transport: 'http_sse'
|
179
|
+
})
|
180
|
+
end
|
181
|
+
|
182
|
+
def send_heartbeat(stream)
|
183
|
+
send_event(stream, 'heartbeat', {
|
184
|
+
timestamp: Time.now.iso8601
|
185
|
+
})
|
186
|
+
end
|
187
|
+
|
188
|
+
|
189
|
+
def start_heartbeat(stream)
|
190
|
+
interval = @test_mode ? TEST_HEARTBEAT_INTERVAL : HEARTBEAT_INTERVAL
|
191
|
+
@heartbeat_thread = Thread.new do
|
192
|
+
loop do
|
193
|
+
sleep interval
|
194
|
+
send_heartbeat(stream)
|
195
|
+
end
|
196
|
+
rescue IOError, Errno::EPIPE, Errno::ECONNRESET => e
|
197
|
+
# Client disconnected - signal main event loop to exit
|
198
|
+
$stderr.puts "[HEARTBEAT] Client disconnect detected: #{e.class}"
|
199
|
+
@stop_flag = true
|
200
|
+
rescue StandardError => e
|
201
|
+
# Unexpected error - signal main loop to exit
|
202
|
+
$stderr.puts "[HEARTBEAT] Unexpected error: #{e.class}: #{e.message}"
|
203
|
+
@stop_flag = true
|
204
|
+
end
|
205
|
+
end
|
206
|
+
|
207
|
+
def stop_heartbeat
|
208
|
+
@heartbeat_thread&.kill
|
209
|
+
@heartbeat_thread = nil
|
210
|
+
end
|
211
|
+
|
212
|
+
def register_connection
|
213
|
+
# Connection registered in transport when handler is created
|
214
|
+
end
|
215
|
+
|
216
|
+
def unregister_connection
|
217
|
+
# Remove connection from transport's connection tracking
|
218
|
+
$stderr.puts "[SSE] Unregistering connection: #{@session_id}, transport present: #{!@transport.nil?}"
|
219
|
+
if @transport
|
220
|
+
$stderr.puts "[SSE] About to call transport.unregister_connection"
|
221
|
+
begin
|
222
|
+
@transport.unregister_connection(@session_id)
|
223
|
+
$stderr.puts "[SSE] Called transport.unregister_connection"
|
224
|
+
rescue => e
|
225
|
+
$stderr.puts "[SSE] ERROR calling unregister: #{e.class}: #{e.message}"
|
226
|
+
$stderr.puts "[SSE] Backtrace: #{e.backtrace.first(3).join('\n')}"
|
227
|
+
end
|
228
|
+
else
|
229
|
+
$stderr.puts "[SSE] WARNING: No transport to unregister from!"
|
230
|
+
end
|
231
|
+
$stderr.puts "[SSE] Unregistration complete"
|
232
|
+
end
|
233
|
+
end
|
234
|
+
end
|
235
|
+
end
|
236
|
+
end
|
data/lib/mathpix/mcp.rb
ADDED
@@ -0,0 +1,52 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# MCP (Model Context Protocol) integration for Mathpix
|
4
|
+
#
|
5
|
+
# This module provides MCP server functionality using the official Ruby MCP SDK.
|
6
|
+
# All 9 tools are thin delegates to the core Mathpix::Client.
|
7
|
+
#
|
8
|
+
# Usage:
|
9
|
+
# require 'mathpix/mcp'
|
10
|
+
#
|
11
|
+
# Mathpix.configure do |config|
|
12
|
+
# config.app_id = ENV['MATHPIX_APP_ID']
|
13
|
+
# config.app_key = ENV['MATHPIX_APP_KEY']
|
14
|
+
# end
|
15
|
+
#
|
16
|
+
# Mathpix::MCP::Server.run
|
17
|
+
|
18
|
+
# Load MCP components
|
19
|
+
require_relative 'mcp/server'
|
20
|
+
require_relative 'mcp/base_tool'
|
21
|
+
|
22
|
+
# Load transports (HTTP/SSE)
|
23
|
+
require_relative 'mcp/transports'
|
24
|
+
|
25
|
+
# Load middleware (CORS, OAuth)
|
26
|
+
require_relative 'mcp/middleware'
|
27
|
+
|
28
|
+
# Load authentication
|
29
|
+
require_relative 'mcp/auth'
|
30
|
+
|
31
|
+
# Load elicitations (interactive prompts for low-confidence OCR)
|
32
|
+
require_relative 'mcp/elicitations'
|
33
|
+
|
34
|
+
# Load hierarchical resources
|
35
|
+
require_relative 'mcp/resources'
|
36
|
+
|
37
|
+
# Load all 9 tools
|
38
|
+
require_relative 'mcp/tools/convert_image_tool'
|
39
|
+
require_relative 'mcp/tools/convert_document_tool'
|
40
|
+
require_relative 'mcp/tools/convert_strokes_tool'
|
41
|
+
require_relative 'mcp/tools/batch_convert_tool'
|
42
|
+
require_relative 'mcp/tools/check_document_status_tool'
|
43
|
+
require_relative 'mcp/tools/search_results_tool'
|
44
|
+
require_relative 'mcp/tools/get_usage_tool'
|
45
|
+
require_relative 'mcp/tools/get_account_info_tool'
|
46
|
+
require_relative 'mcp/tools/list_formats_tool'
|
47
|
+
|
48
|
+
# Load all 4 resource handlers
|
49
|
+
require_relative 'mcp/resources/latest_snip_resource'
|
50
|
+
require_relative 'mcp/resources/recent_snips_resource'
|
51
|
+
require_relative 'mcp/resources/snip_stats_resource'
|
52
|
+
require_relative 'mcp/resources/formats_list_resource'
|
@@ -0,0 +1,364 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Mathpix
|
4
|
+
# OCR Result object
|
5
|
+
# The geodesic path: rich, intuitive, method-based access
|
6
|
+
class Result
|
7
|
+
attr_reader :data, :source_path
|
8
|
+
|
9
|
+
def initialize(data, source_path = nil)
|
10
|
+
@data = data
|
11
|
+
@source_path = source_path
|
12
|
+
end
|
13
|
+
|
14
|
+
# Get source URL if image was processed from URL (feature parity with mpxpy)
|
15
|
+
#
|
16
|
+
# @return [String, nil] URL if source was remote, nil otherwise
|
17
|
+
def source_url
|
18
|
+
return nil unless @source_path.is_a?(String)
|
19
|
+
@source_path.start_with?('http://', 'https://') ? @source_path : nil
|
20
|
+
end
|
21
|
+
|
22
|
+
# Text result
|
23
|
+
# @return [String, nil]
|
24
|
+
def text
|
25
|
+
data['text']
|
26
|
+
end
|
27
|
+
|
28
|
+
# LaTeX styled output
|
29
|
+
# @return [String, nil]
|
30
|
+
def latex
|
31
|
+
data['latex_styled'] || data['latex']
|
32
|
+
end
|
33
|
+
|
34
|
+
alias latex_styled latex
|
35
|
+
|
36
|
+
# Simplified LaTeX
|
37
|
+
# @return [String, nil]
|
38
|
+
def latex_simplified
|
39
|
+
data['latex_simplified']
|
40
|
+
end
|
41
|
+
|
42
|
+
# MathML output
|
43
|
+
# @return [String, nil]
|
44
|
+
def mathml
|
45
|
+
data['mathml']
|
46
|
+
end
|
47
|
+
|
48
|
+
# AsciiMath output
|
49
|
+
# @return [String, nil]
|
50
|
+
def asciimath
|
51
|
+
data['asciimath']
|
52
|
+
end
|
53
|
+
|
54
|
+
# HTML output (may contain SVG)
|
55
|
+
# @return [String, nil]
|
56
|
+
def html
|
57
|
+
data['html']
|
58
|
+
end
|
59
|
+
|
60
|
+
# Confidence score
|
61
|
+
# @return [Float] 0.0-1.0
|
62
|
+
def confidence
|
63
|
+
data['confidence'] || 0.0
|
64
|
+
end
|
65
|
+
|
66
|
+
# Confidence rate (alternative)
|
67
|
+
# @return [Float] 0.0-1.0
|
68
|
+
def confidence_rate
|
69
|
+
data['confidence_rate'] || confidence
|
70
|
+
end
|
71
|
+
|
72
|
+
# Created timestamp
|
73
|
+
# @return [Time, nil]
|
74
|
+
def created_at
|
75
|
+
Time.parse(data['created']) if data['created']
|
76
|
+
rescue ArgumentError
|
77
|
+
nil
|
78
|
+
end
|
79
|
+
|
80
|
+
# Request ID
|
81
|
+
# @return [String, nil]
|
82
|
+
def request_id
|
83
|
+
data['request_id']
|
84
|
+
end
|
85
|
+
|
86
|
+
# Processing time in milliseconds
|
87
|
+
# @return [Integer, nil]
|
88
|
+
def processing_time_ms
|
89
|
+
data['processing_time_ms']
|
90
|
+
end
|
91
|
+
|
92
|
+
# Is content printed (vs handwritten)?
|
93
|
+
# @return [Boolean]
|
94
|
+
def printed?
|
95
|
+
data['is_printed'] == true
|
96
|
+
end
|
97
|
+
|
98
|
+
# Is content handwritten?
|
99
|
+
# @return [Boolean]
|
100
|
+
def handwritten?
|
101
|
+
data['is_handwritten'] == true
|
102
|
+
end
|
103
|
+
|
104
|
+
# Contains chart?
|
105
|
+
# @return [Boolean]
|
106
|
+
def chart?
|
107
|
+
data['contains_chart'] == true
|
108
|
+
end
|
109
|
+
|
110
|
+
# Contains diagram?
|
111
|
+
# @return [Boolean]
|
112
|
+
def diagram?
|
113
|
+
data['contains_diagram'] == true
|
114
|
+
end
|
115
|
+
|
116
|
+
# Contains table?
|
117
|
+
# @return [Boolean]
|
118
|
+
def table?
|
119
|
+
data['contains_table'] == true
|
120
|
+
end
|
121
|
+
|
122
|
+
# Get metadata
|
123
|
+
# @return [Hash]
|
124
|
+
def metadata
|
125
|
+
data['metadata'] || {}
|
126
|
+
end
|
127
|
+
|
128
|
+
# Get tags
|
129
|
+
# @return [Array<String>]
|
130
|
+
def tags
|
131
|
+
data['tags'] || []
|
132
|
+
end
|
133
|
+
|
134
|
+
# --- Line-by-line data (feature parity with mpxpy) ---
|
135
|
+
|
136
|
+
# Get line-by-line data with bounding boxes
|
137
|
+
#
|
138
|
+
# Requires snap(..., include_line_data: true)
|
139
|
+
# Feature parity with Python mpxpy's lines_json() method
|
140
|
+
#
|
141
|
+
# @return [Array<Line>] array of line objects
|
142
|
+
def lines
|
143
|
+
return [] unless data['line_data']
|
144
|
+
|
145
|
+
@lines ||= data['line_data'].map { |line_data| Line.new(line_data) }
|
146
|
+
end
|
147
|
+
|
148
|
+
# Get lines as JSON array (raw data)
|
149
|
+
# @return [Array<Hash>]
|
150
|
+
def lines_json
|
151
|
+
data['line_data'] || []
|
152
|
+
end
|
153
|
+
|
154
|
+
# Line data structure for bounding boxes and confidence
|
155
|
+
class Line
|
156
|
+
attr_reader :data
|
157
|
+
|
158
|
+
def initialize(data)
|
159
|
+
@data = data
|
160
|
+
end
|
161
|
+
|
162
|
+
# Line text content
|
163
|
+
# @return [String]
|
164
|
+
def text
|
165
|
+
data['text'] || ''
|
166
|
+
end
|
167
|
+
|
168
|
+
# Line confidence
|
169
|
+
# @return [Float]
|
170
|
+
def confidence
|
171
|
+
data['confidence'] || 0.0
|
172
|
+
end
|
173
|
+
|
174
|
+
# Bounding box coordinates [x, y, width, height]
|
175
|
+
# @return [Array<Integer>, nil]
|
176
|
+
def bbox
|
177
|
+
data['bbox']
|
178
|
+
end
|
179
|
+
|
180
|
+
alias bounding_box bbox
|
181
|
+
|
182
|
+
# Is this line handwritten?
|
183
|
+
# @return [Boolean]
|
184
|
+
def handwritten?
|
185
|
+
data['type'] == 'handwriting'
|
186
|
+
end
|
187
|
+
|
188
|
+
# Is this line printed?
|
189
|
+
# @return [Boolean]
|
190
|
+
def printed?
|
191
|
+
data['type'] == 'printed' || data['type'] == 'print'
|
192
|
+
end
|
193
|
+
|
194
|
+
# LaTeX for this line
|
195
|
+
# @return [String, nil]
|
196
|
+
def latex
|
197
|
+
data['latex']
|
198
|
+
end
|
199
|
+
|
200
|
+
# MathML for this line
|
201
|
+
# @return [String, nil]
|
202
|
+
def mathml
|
203
|
+
data['mathml']
|
204
|
+
end
|
205
|
+
|
206
|
+
# Word-level data (if available)
|
207
|
+
# @return [Array<Word>]
|
208
|
+
def words
|
209
|
+
return [] unless data['words']
|
210
|
+
|
211
|
+
@words ||= data['words'].map { |word_data| Word.new(word_data) }
|
212
|
+
end
|
213
|
+
|
214
|
+
# Convert to hash
|
215
|
+
# @return [Hash]
|
216
|
+
def to_h
|
217
|
+
data
|
218
|
+
end
|
219
|
+
|
220
|
+
# Inspect
|
221
|
+
# @return [String]
|
222
|
+
def inspect
|
223
|
+
"#<Mathpix::Result::Line text=\"#{text&.[](0..30)}\" confidence=#{confidence}>"
|
224
|
+
end
|
225
|
+
end
|
226
|
+
|
227
|
+
# Word data structure for fine-grained bounding boxes
|
228
|
+
class Word
|
229
|
+
attr_reader :data
|
230
|
+
|
231
|
+
def initialize(data)
|
232
|
+
@data = data
|
233
|
+
end
|
234
|
+
|
235
|
+
# Word text
|
236
|
+
# @return [String]
|
237
|
+
def text
|
238
|
+
data['text'] || ''
|
239
|
+
end
|
240
|
+
|
241
|
+
# Word confidence
|
242
|
+
# @return [Float]
|
243
|
+
def confidence
|
244
|
+
data['confidence'] || 0.0
|
245
|
+
end
|
246
|
+
|
247
|
+
# Word bounding box
|
248
|
+
# @return [Array<Integer>, nil]
|
249
|
+
def bbox
|
250
|
+
data['bbox']
|
251
|
+
end
|
252
|
+
|
253
|
+
# Convert to hash
|
254
|
+
# @return [Hash]
|
255
|
+
def to_h
|
256
|
+
data
|
257
|
+
end
|
258
|
+
|
259
|
+
# Inspect
|
260
|
+
# @return [String]
|
261
|
+
def inspect
|
262
|
+
"#<Mathpix::Result::Word text=\"#{text}\" confidence=#{confidence}>"
|
263
|
+
end
|
264
|
+
end
|
265
|
+
|
266
|
+
# Convert to hash
|
267
|
+
# @return [Hash]
|
268
|
+
def to_h
|
269
|
+
data
|
270
|
+
end
|
271
|
+
|
272
|
+
# Convert to JSON
|
273
|
+
# @return [String]
|
274
|
+
def to_json(*args)
|
275
|
+
data.to_json(*args)
|
276
|
+
end
|
277
|
+
|
278
|
+
# Inspect
|
279
|
+
# @return [String]
|
280
|
+
def inspect
|
281
|
+
"#<Mathpix::Result confidence=#{confidence} text=#{text&.[](0..50)}>"
|
282
|
+
end
|
283
|
+
|
284
|
+
# --- Chemistry-specific methods ---
|
285
|
+
|
286
|
+
# SMILES notation (chemistry)
|
287
|
+
# @return [String, nil]
|
288
|
+
def smiles
|
289
|
+
text if chemistry?
|
290
|
+
end
|
291
|
+
|
292
|
+
# InChI notation (chemistry)
|
293
|
+
# @return [String, nil]
|
294
|
+
def inchi
|
295
|
+
data['inchi']
|
296
|
+
end
|
297
|
+
|
298
|
+
# InChI Key (chemistry)
|
299
|
+
# @return [String, nil]
|
300
|
+
def inchikey
|
301
|
+
data['inchikey']
|
302
|
+
end
|
303
|
+
|
304
|
+
# Molecular formula
|
305
|
+
# @return [String, nil]
|
306
|
+
def molecular_formula
|
307
|
+
data['molecular_formula']
|
308
|
+
end
|
309
|
+
|
310
|
+
# Molecular name
|
311
|
+
# @return [String, nil]
|
312
|
+
def molecular_name
|
313
|
+
data['molecular_name']
|
314
|
+
end
|
315
|
+
|
316
|
+
# Molecular weight
|
317
|
+
# @return [Float, nil]
|
318
|
+
def molecular_weight
|
319
|
+
data['molecular_weight']
|
320
|
+
end
|
321
|
+
|
322
|
+
# Has stereochemistry?
|
323
|
+
# @return [Boolean]
|
324
|
+
def stereochemistry?
|
325
|
+
data['has_stereochemistry'] == true || smiles&.include?('@')
|
326
|
+
end
|
327
|
+
|
328
|
+
# Is this a chemistry result?
|
329
|
+
# @return [Boolean]
|
330
|
+
def chemistry?
|
331
|
+
molecular_formula || inchi || smiles&.match?(/^[A-Za-z0-9@\[\]\(\)=#\+\-\\\/\.]+$/)
|
332
|
+
end
|
333
|
+
|
334
|
+
# --- Success/Failure ---
|
335
|
+
|
336
|
+
# Was capture successful?
|
337
|
+
# @return [Boolean]
|
338
|
+
def success?
|
339
|
+
!text.nil? && confidence >= 0.5
|
340
|
+
end
|
341
|
+
|
342
|
+
# Was capture a failure?
|
343
|
+
# @return [Boolean]
|
344
|
+
def failure?
|
345
|
+
!success?
|
346
|
+
end
|
347
|
+
|
348
|
+
# Execute block if successful
|
349
|
+
# @yield [self]
|
350
|
+
# @return [self]
|
351
|
+
def on_success
|
352
|
+
yield self if success?
|
353
|
+
self
|
354
|
+
end
|
355
|
+
|
356
|
+
# Execute block if failed
|
357
|
+
# @yield [self]
|
358
|
+
# @return [self]
|
359
|
+
def on_failure
|
360
|
+
yield self if failure?
|
361
|
+
self
|
362
|
+
end
|
363
|
+
end
|
364
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Mathpix
|
4
|
+
# Gem version following semantic versioning
|
5
|
+
VERSION = '0.1.0'
|
6
|
+
|
7
|
+
# Seed for deterministic behavior
|
8
|
+
SEED = 1069
|
9
|
+
|
10
|
+
# Balanced ternary pattern
|
11
|
+
BALANCED_TERNARY = [+1, -1, -1, +1, +1, +1, +1].freeze
|
12
|
+
|
13
|
+
# Version info
|
14
|
+
def self.version_info
|
15
|
+
{
|
16
|
+
version: VERSION,
|
17
|
+
seed: SEED,
|
18
|
+
balanced_ternary: BALANCED_TERNARY,
|
19
|
+
ruby_version: RUBY_VERSION
|
20
|
+
}
|
21
|
+
end
|
22
|
+
end
|