vector_mcp 0.1.0 → 0.2.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.
@@ -28,6 +28,51 @@ module VectorMCP
28
28
  inputSchema: input_schema # Expected to be a Hash representing JSON Schema
29
29
  }.compact # Remove nil values
30
30
  end
31
+
32
+ # Checks if this tool supports image inputs based on its input schema.
33
+ # @return [Boolean] True if the tool's input schema includes image properties.
34
+ def supports_image_input?
35
+ return false unless input_schema.is_a?(Hash)
36
+
37
+ properties = extract_schema_properties
38
+ properties.any? { |_prop, schema| image_property?(schema) }
39
+ end
40
+
41
+ private
42
+
43
+ # Extracts properties from the input schema, handling both string and symbol keys.
44
+ def extract_schema_properties
45
+ input_schema["properties"] || input_schema[:properties] || {}
46
+ end
47
+
48
+ # Checks if a property schema represents an image input.
49
+ def image_property?(schema)
50
+ return false unless schema.is_a?(Hash)
51
+
52
+ explicit_image_format?(schema) || base64_image_content?(schema)
53
+ end
54
+
55
+ # Checks if the schema explicitly declares image format.
56
+ def explicit_image_format?(schema)
57
+ schema["format"] == "image" || schema[:format] == "image"
58
+ end
59
+
60
+ # Checks if the schema represents base64-encoded image content.
61
+ def base64_image_content?(schema)
62
+ string_type_with_base64_encoding?(schema) && image_media_type?(schema)
63
+ end
64
+
65
+ # Checks if the schema is a string type with base64 encoding.
66
+ def string_type_with_base64_encoding?(schema)
67
+ (schema["type"] == "string" && schema["contentEncoding"] == "base64") ||
68
+ (schema[:type] == "string" && schema[:contentEncoding] == "base64")
69
+ end
70
+
71
+ # Checks if the schema has an image media type.
72
+ def image_media_type?(schema)
73
+ schema["contentMediaType"]&.start_with?("image/") ||
74
+ schema[:contentMediaType]&.start_with?("image/")
75
+ end
31
76
  end
32
77
 
33
78
  # Represents a resource (context or data) that can be provided to the AI model or user.
@@ -39,7 +84,7 @@ module VectorMCP
39
84
  # @!attribute description [rw] String
40
85
  # A description of the resource content.
41
86
  # @!attribute mime_type [rw] String
42
- # The MIME type of the resource content (e.g., "text/plain", "application/json").
87
+ # The MIME type of the resource content (e.g., "text/plain", "application/json", "image/jpeg").
43
88
  # @!attribute handler [rw] Proc
44
89
  # A callable that returns the content of the resource. It may receive parameters from the request (e.g., for dynamic resources).
45
90
  Resource = Struct.new(:uri, :name, :description, :mime_type, :handler) do
@@ -53,6 +98,64 @@ module VectorMCP
53
98
  mimeType: mime_type
54
99
  }.compact
55
100
  end
101
+
102
+ # Checks if this resource represents an image.
103
+ # @return [Boolean] True if the resource's MIME type indicates an image.
104
+ def image_resource?
105
+ !!mime_type&.start_with?("image/")
106
+ end
107
+
108
+ # Class method to create an image resource from a file path.
109
+ # @param uri [String] The URI for the resource.
110
+ # @param file_path [String] Path to the image file.
111
+ # @param name [String] Human-readable name for the resource.
112
+ # @param description [String] Description of the resource.
113
+ # @return [Resource] A new Resource instance configured for the image file.
114
+ def self.from_image_file(uri:, file_path:, name: nil, description: nil)
115
+ raise ArgumentError, "Image file not found: #{file_path}" unless File.exist?(file_path)
116
+
117
+ # Auto-detect MIME type
118
+ require_relative "image_util"
119
+ image_data = File.binread(file_path)
120
+ detected_mime_type = VectorMCP::ImageUtil.detect_image_format(image_data)
121
+
122
+ raise ArgumentError, "Could not detect image format for file: #{file_path}" unless detected_mime_type
123
+
124
+ # Generate name and description if not provided
125
+ default_name = name || File.basename(file_path)
126
+ default_description = description || "Image file: #{file_path}"
127
+
128
+ handler = lambda do |_params|
129
+ VectorMCP::ImageUtil.file_to_mcp_image_content(file_path)
130
+ end
131
+
132
+ new(uri, default_name, default_description, detected_mime_type, handler)
133
+ end
134
+
135
+ # Class method to create an image resource from binary data.
136
+ # @param uri [String] The URI for the resource.
137
+ # @param image_data [String] Binary image data.
138
+ # @param name [String] Human-readable name for the resource.
139
+ # @param description [String] Description of the resource.
140
+ # @param mime_type [String, nil] MIME type (auto-detected if nil).
141
+ # @return [Resource] A new Resource instance configured for the image data.
142
+ def self.from_image_data(uri:, image_data:, name:, description: nil, mime_type: nil)
143
+ require_relative "image_util"
144
+
145
+ # Detect or validate MIME type
146
+ detected_mime_type = VectorMCP::ImageUtil.detect_image_format(image_data)
147
+ final_mime_type = mime_type || detected_mime_type
148
+
149
+ raise ArgumentError, "Could not determine MIME type for image data" unless final_mime_type
150
+
151
+ default_description = description || "Image resource: #{name}"
152
+
153
+ handler = lambda do |_params|
154
+ VectorMCP::ImageUtil.to_mcp_image_content(image_data, mime_type: final_mime_type)
155
+ end
156
+
157
+ new(uri, name, default_description, final_mime_type, handler)
158
+ end
56
159
  end
57
160
 
58
161
  # Represents a prompt or templated message workflow for users or AI models.
@@ -76,6 +179,112 @@ module VectorMCP
76
179
  arguments: arguments # Expected to be an array of { name:, description:, required: } hashes
77
180
  }.compact
78
181
  end
182
+
183
+ # Checks if this prompt supports image arguments.
184
+ # @return [Boolean] True if any of the prompt arguments are configured for images.
185
+ def supports_image_arguments?
186
+ return false unless arguments.is_a?(Array)
187
+
188
+ arguments.any? do |arg|
189
+ arg.is_a?(Hash) && (
190
+ arg["type"] == "image" ||
191
+ arg[:type] == "image" ||
192
+ (arg["description"] || arg[:description])&.downcase&.include?("image")
193
+ )
194
+ end
195
+ end
196
+
197
+ # Class method to create an image-enabled prompt with common image argument patterns.
198
+ # @param name [String] The unique name of the prompt.
199
+ # @param description [String] A human-readable description.
200
+ # @param image_argument_name [String] Name of the image argument (default: "image").
201
+ # @param additional_arguments [Array<Hash>] Additional prompt arguments.
202
+ # @param handler [Proc] The prompt handler.
203
+ # @return [Prompt] A new Prompt instance configured for image input.
204
+ def self.with_image_support(name:, description:, image_argument_name: "image", additional_arguments: [], &handler)
205
+ image_arg = {
206
+ name: image_argument_name,
207
+ description: "Image file path or image data to include in the prompt",
208
+ required: false,
209
+ type: "image"
210
+ }
211
+
212
+ all_arguments = [image_arg] + additional_arguments
213
+
214
+ new(name, description, all_arguments, handler)
215
+ end
216
+ end
217
+
218
+ # Represents an MCP root definition.
219
+ # Roots define filesystem boundaries where servers can operate.
220
+ Root = Struct.new(:uri, :name) do
221
+ # Converts the root to its MCP definition hash.
222
+ # @return [Hash] A hash representing the root in MCP format.
223
+ def as_mcp_definition
224
+ {
225
+ uri: uri.to_s,
226
+ name: name
227
+ }.compact
228
+ end
229
+
230
+ # Validates that the root URI is properly formatted and secure.
231
+ # @return [Boolean] True if the root is valid.
232
+ # @raise [ArgumentError] If the root is invalid.
233
+ def validate!
234
+ # Validate URI format
235
+ parsed_uri = begin
236
+ URI(uri.to_s)
237
+ rescue URI::InvalidURIError
238
+ raise ArgumentError, "Invalid URI format: #{uri}"
239
+ end
240
+
241
+ # Currently, only file:// scheme is supported per MCP spec
242
+ raise ArgumentError, "Only file:// URIs are supported for roots, got: #{parsed_uri.scheme}://" unless parsed_uri.scheme == "file"
243
+
244
+ # Validate path exists and is a directory
245
+ path = parsed_uri.path
246
+ raise ArgumentError, "Root directory does not exist: #{path}" unless File.exist?(path)
247
+
248
+ raise ArgumentError, "Root path is not a directory: #{path}" unless File.directory?(path)
249
+
250
+ # Security check: ensure we can read the directory
251
+ raise ArgumentError, "Root directory is not readable: #{path}" unless File.readable?(path)
252
+
253
+ # Validate against path traversal attempts in the URI itself
254
+ raise ArgumentError, "Root path contains unsafe traversal patterns: #{path}" if path.include?("..") || path.include?("./")
255
+
256
+ true
257
+ end
258
+
259
+ # Class method to create a root from a local directory path.
260
+ # @param path [String] Local filesystem path to the directory.
261
+ # @param name [String] Human-readable name for the root.
262
+ # @return [Root] A new Root instance.
263
+ # @raise [ArgumentError] If the path is invalid or not accessible.
264
+ def self.from_path(path, name: nil)
265
+ # Expand path to get absolute path and resolve any relative components
266
+ expanded_path = File.expand_path(path)
267
+
268
+ # Create file:// URI
269
+ uri = "file://#{expanded_path}"
270
+
271
+ # Generate name if not provided
272
+ default_name = name || File.basename(expanded_path)
273
+
274
+ root = new(uri, default_name)
275
+ root.validate! # Ensure the root is valid
276
+ root
277
+ end
278
+
279
+ # Returns the filesystem path for file:// URIs.
280
+ # @return [String] The filesystem path.
281
+ # @raise [ArgumentError] If the URI is not a file:// scheme.
282
+ def path
283
+ parsed_uri = URI(uri.to_s)
284
+ raise ArgumentError, "Cannot get path for non-file URI: #{uri}" unless parsed_uri.scheme == "file"
285
+
286
+ parsed_uri.path
287
+ end
79
288
  end
80
289
  end
81
290
  end
@@ -135,4 +135,43 @@ module VectorMCP
135
135
  super(message, code: -32_001, details: details, request_id: request_id)
136
136
  end
137
137
  end
138
+
139
+ # --- Sampling Specific Errors ---
140
+ # These are errors that can occur when a server initiates a `sampling/createMessage` request.
141
+
142
+ # Base class for sampling-related errors originating from client or transport during a server-initiated sample request.
143
+ class SamplingError < ProtocolError
144
+ # @param message [String] The error message.
145
+ # @param code [Integer] JSON-RPC error code (typically from client response, or server defined for timeouts etc).
146
+ # @param details [Object, nil] Optional additional error data from client or transport.
147
+ # @param request_id [String, Integer, nil] The ID of the `sampling/createMessage` request.
148
+ def initialize(message = "An error occurred during sampling.", code: -32_050, details: nil, request_id: nil)
149
+ # Using -32050 as a generic base for sampling errors from server's perspective.
150
+ # Specific errors from client might have different codes.
151
+ super
152
+ end
153
+ end
154
+
155
+ # Raised when a server-initiated sampling request times out waiting for a client response.
156
+ class SamplingTimeoutError < SamplingError
157
+ def initialize(message = "Timeout waiting for client response to sampling request.", details: nil, request_id: nil)
158
+ super(message, code: -32_051, details: details, request_id: request_id) # Server-defined code for this timeout
159
+ end
160
+ end
161
+
162
+ # Raised if the client explicitly rejects or denies the sampling request (e.g., user vetoed).
163
+ # This would typically correspond to a specific error response from the client.
164
+ class SamplingRejectedError < SamplingError
165
+ def initialize(message = "Client rejected the sampling request.", code: -32_052, details: nil, request_id: nil)
166
+ # This code might be overridden by the actual error code from the client if available.
167
+ super
168
+ end
169
+ end
170
+
171
+ # Placeholder for other specific sampling errors that might be defined based on client responses.
172
+ # class SpecificSamplingClientError < SamplingError
173
+ # def initialize(message, client_code:, client_details:, request_id: nil)
174
+ # super(message, code: client_code, details: client_details, request_id: request_id)
175
+ # end
176
+ # end
138
177
  end
@@ -115,6 +115,23 @@ module VectorMCP
115
115
  result
116
116
  end
117
117
 
118
+ # Handles the `roots/list` request.
119
+ # Returns the list of available roots and clears the `listChanged` flag.
120
+ #
121
+ # @param _params [Hash] The request parameters (ignored).
122
+ # @param _session [VectorMCP::Session] The current session (ignored).
123
+ # @param server [VectorMCP::Server] The server instance.
124
+ # @return [Hash] A hash containing an array of root definitions.
125
+ # Example: `{ roots: [ { uri: "file:///path/to/dir", name: "My Project" } ] }`
126
+ def self.list_roots(_params, _session, server)
127
+ # Once the list is supplied, clear the listChanged flag
128
+ result = {
129
+ roots: server.roots.values.map(&:as_mcp_definition)
130
+ }
131
+ server.clear_roots_list_changed if server.respond_to?(:clear_roots_list_changed)
132
+ result
133
+ end
134
+
118
135
  # Handles the `prompts/subscribe` request (placeholder).
119
136
  # This implementation is a simple acknowledgement.
120
137
  #
@@ -0,0 +1,358 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "stringio"
5
+
6
+ module VectorMCP
7
+ # Provides comprehensive image handling utilities for VectorMCP operations,
8
+ # including format detection, validation, encoding/decoding, and conversion
9
+ # to MCP-compliant image content format.
10
+ module ImageUtil
11
+ module_function
12
+
13
+ # Common image MIME types and their magic byte signatures
14
+ IMAGE_SIGNATURES = {
15
+ "image/jpeg" => [
16
+ [0xFF, 0xD8, 0xFF].pack("C*"),
17
+ [0xFF, 0xD8].pack("C*")
18
+ ],
19
+ "image/png" => [[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A].pack("C*")],
20
+ "image/gif" => %w[
21
+ GIF87a
22
+ GIF89a
23
+ ],
24
+ "image/webp" => [
25
+ "WEBP"
26
+ ],
27
+ "image/bmp" => [
28
+ "BM"
29
+ ],
30
+ "image/tiff" => [
31
+ "II*\0",
32
+ "MM\0*"
33
+ ]
34
+ }.freeze
35
+
36
+ # Maximum image size in bytes (default: 10MB)
37
+ DEFAULT_MAX_SIZE = 10 * 1024 * 1024
38
+
39
+ # Detects the MIME type of image data based on magic bytes.
40
+ #
41
+ # @param data [String] The binary image data.
42
+ # @return [String, nil] The detected MIME type, or nil if not recognized.
43
+ #
44
+ # @example
45
+ # VectorMCP::ImageUtil.detect_image_format(File.binread("image.jpg"))
46
+ # # => "image/jpeg"
47
+ def detect_image_format(data)
48
+ return nil if data.nil? || data.empty?
49
+
50
+ # Ensure we have binary data (dup to avoid modifying frozen strings)
51
+ binary_data = data.dup.force_encoding(Encoding::ASCII_8BIT)
52
+
53
+ IMAGE_SIGNATURES.each do |mime_type, signatures|
54
+ signatures.each do |signature|
55
+ case mime_type
56
+ when "image/webp"
57
+ # WebP files start with RIFF then have WEBP at offset 8
58
+ return mime_type if binary_data.start_with?("RIFF") && binary_data[8, 4] == signature
59
+ else
60
+ return mime_type if binary_data.start_with?(signature)
61
+ end
62
+ end
63
+ end
64
+
65
+ nil
66
+ end
67
+
68
+ # Validates if the provided data is a valid image.
69
+ #
70
+ # @param data [String] The binary image data.
71
+ # @param max_size [Integer] Maximum allowed size in bytes.
72
+ # @param allowed_formats [Array<String>] Allowed MIME types.
73
+ # @return [Hash] Validation result with :valid, :mime_type, and :errors keys.
74
+ #
75
+ # @example
76
+ # result = VectorMCP::ImageUtil.validate_image(image_data)
77
+ # if result[:valid]
78
+ # puts "Valid #{result[:mime_type]} image"
79
+ # else
80
+ # puts "Errors: #{result[:errors].join(', ')}"
81
+ # end
82
+ def validate_image(data, max_size: DEFAULT_MAX_SIZE, allowed_formats: nil)
83
+ errors = []
84
+
85
+ if data.nil? || data.empty?
86
+ errors << "Image data is empty"
87
+ return { valid: false, mime_type: nil, errors: errors }
88
+ end
89
+
90
+ # Check file size
91
+ errors << "Image size (#{data.bytesize} bytes) exceeds maximum allowed size (#{max_size} bytes)" if data.bytesize > max_size
92
+
93
+ # Detect format
94
+ mime_type = detect_image_format(data)
95
+ if mime_type.nil?
96
+ errors << "Unrecognized or invalid image format"
97
+ return { valid: false, mime_type: nil, errors: errors }
98
+ end
99
+
100
+ # Check allowed formats
101
+ if allowed_formats && !allowed_formats.include?(mime_type)
102
+ errors << "Image format #{mime_type} is not allowed. Allowed formats: #{allowed_formats.join(", ")}"
103
+ end
104
+
105
+ {
106
+ valid: errors.empty?,
107
+ mime_type: mime_type,
108
+ size: data.bytesize,
109
+ errors: errors
110
+ }
111
+ end
112
+
113
+ # Encodes binary image data to base64 string.
114
+ #
115
+ # @param data [String] The binary image data.
116
+ # @return [String] Base64 encoded string.
117
+ #
118
+ # @example
119
+ # encoded = VectorMCP::ImageUtil.encode_base64(File.binread("image.jpg"))
120
+ def encode_base64(data)
121
+ Base64.strict_encode64(data)
122
+ end
123
+
124
+ # Decodes base64 string to binary image data.
125
+ #
126
+ # @param base64_string [String] Base64 encoded image data.
127
+ # @return [String] Binary image data.
128
+ # @raise [ArgumentError] If base64 string is invalid.
129
+ #
130
+ # @example
131
+ # data = VectorMCP::ImageUtil.decode_base64(encoded_string)
132
+ def decode_base64(base64_string)
133
+ Base64.strict_decode64(base64_string)
134
+ rescue ArgumentError => e
135
+ raise ArgumentError, "Invalid base64 encoding: #{e.message}"
136
+ end
137
+
138
+ # Converts image data to MCP-compliant image content format.
139
+ #
140
+ # @param data [String] Binary image data or base64 encoded string.
141
+ # @param mime_type [String, nil] MIME type (auto-detected if nil).
142
+ # @param validate [Boolean] Whether to validate the image data.
143
+ # @param max_size [Integer] Maximum allowed size for validation.
144
+ # @return [Hash] MCP image content hash with :type, :data, and :mimeType.
145
+ # @raise [ArgumentError] If validation fails.
146
+ #
147
+ # @example Convert binary image data
148
+ # content = VectorMCP::ImageUtil.to_mcp_image_content(
149
+ # File.binread("image.jpg")
150
+ # )
151
+ # # => { type: "image", data: "base64...", mimeType: "image/jpeg" }
152
+ #
153
+ # @example Convert base64 string with explicit MIME type
154
+ # content = VectorMCP::ImageUtil.to_mcp_image_content(
155
+ # base64_string,
156
+ # mime_type: "image/png",
157
+ # validate: false
158
+ # )
159
+ def to_mcp_image_content(data, mime_type: nil, validate: true, max_size: DEFAULT_MAX_SIZE)
160
+ binary_data, base64_data = process_image_data(data)
161
+ detected_mime_type = validate_and_detect_format(binary_data, validate, max_size)
162
+ final_mime_type = determine_final_mime_type(mime_type, detected_mime_type)
163
+
164
+ {
165
+ type: "image",
166
+ data: base64_data,
167
+ mimeType: final_mime_type
168
+ }
169
+ end
170
+
171
+ # Processes input data to extract both binary and base64 representations.
172
+ # @api private
173
+ def process_image_data(data)
174
+ is_base64 = base64_string?(data)
175
+
176
+ if is_base64
177
+ # Decode to validate and detect format
178
+ begin
179
+ binary_data = decode_base64(data)
180
+ base64_data = data
181
+ rescue ArgumentError => e
182
+ raise ArgumentError, "Invalid base64 image data: #{e.message}"
183
+ end
184
+ else
185
+ # Assume binary data (dup to avoid modifying frozen strings)
186
+ binary_data = data.dup.force_encoding(Encoding::ASCII_8BIT)
187
+ base64_data = encode_base64(binary_data)
188
+ end
189
+
190
+ [binary_data, base64_data]
191
+ end
192
+
193
+ # Validates image data and detects MIME type if validation is enabled.
194
+ # @api private
195
+ def validate_and_detect_format(binary_data, validate, max_size)
196
+ if validate
197
+ validation = validate_image(binary_data, max_size: max_size)
198
+ raise ArgumentError, "Image validation failed: #{validation[:errors].join(", ")}" unless validation[:valid]
199
+
200
+ validation[:mime_type]
201
+ else
202
+ detect_image_format(binary_data)
203
+ end
204
+ end
205
+
206
+ # Determines the final MIME type to use, preferring explicit over detected.
207
+ # @api private
208
+ def determine_final_mime_type(explicit_mime_type, detected_mime_type)
209
+ final_mime_type = explicit_mime_type || detected_mime_type
210
+ raise ArgumentError, "Could not determine image MIME type" if final_mime_type.nil?
211
+
212
+ final_mime_type
213
+ end
214
+
215
+ # Converts file path to MCP-compliant image content.
216
+ #
217
+ # @param file_path [String] Path to the image file.
218
+ # @param validate [Boolean] Whether to validate the image.
219
+ # @param max_size [Integer] Maximum allowed size for validation.
220
+ # @return [Hash] MCP image content hash.
221
+ # @raise [ArgumentError] If file doesn't exist or validation fails.
222
+ #
223
+ # @example
224
+ # content = VectorMCP::ImageUtil.file_to_mcp_image_content("./avatar.png")
225
+ def file_to_mcp_image_content(file_path, validate: true, max_size: DEFAULT_MAX_SIZE)
226
+ raise ArgumentError, "Image file not found: #{file_path}" unless File.exist?(file_path)
227
+
228
+ raise ArgumentError, "Image file not readable: #{file_path}" unless File.readable?(file_path)
229
+
230
+ binary_data = File.binread(file_path)
231
+ to_mcp_image_content(binary_data, validate: validate, max_size: max_size)
232
+ end
233
+
234
+ # Extracts image metadata from binary data.
235
+ #
236
+ # @param data [String] Binary image data.
237
+ # @return [Hash] Metadata hash with available information.
238
+ #
239
+ # @example
240
+ # metadata = VectorMCP::ImageUtil.extract_metadata(image_data)
241
+ # # => { mime_type: "image/jpeg", size: 102400, format: "JPEG" }
242
+ def extract_metadata(data)
243
+ return {} if data.nil? || data.empty?
244
+
245
+ mime_type = detect_image_format(data)
246
+ metadata = {
247
+ size: data.bytesize,
248
+ mime_type: mime_type
249
+ }
250
+
251
+ metadata[:format] = mime_type.split("/").last.upcase if mime_type
252
+
253
+ # Add basic dimension detection for common formats
254
+ metadata.merge!(extract_dimensions(data, mime_type))
255
+ end
256
+
257
+ # Checks if a string appears to be base64 encoded.
258
+ #
259
+ # @param string [String] The string to check.
260
+ # @return [Boolean] True if the string appears to be base64.
261
+ def base64_string?(string)
262
+ return false if string.nil? || string.empty?
263
+
264
+ # Base64 strings should only contain valid base64 characters
265
+ # and be properly padded with correct length
266
+ return false unless string.match?(%r{\A[A-Za-z0-9+/]*={0,2}\z})
267
+
268
+ # Allow both padded and unpadded base64, but require proper structure
269
+ # For unpadded base64, length should be at least 4 and not result in invalid decoding
270
+ if string.include?("=")
271
+ # Padded base64 must be multiple of 4
272
+ (string.length % 4).zero?
273
+ else
274
+ # Unpadded base64 - try to decode to see if it's valid
275
+ return false if string.length < 4
276
+
277
+ begin
278
+ # Add padding and try to decode
279
+ padded = string + ("=" * (4 - (string.length % 4)) % 4)
280
+ Base64.strict_decode64(padded)
281
+ true
282
+ rescue ArgumentError
283
+ false
284
+ end
285
+ end
286
+ end
287
+
288
+ # Extracts basic image dimensions for common formats.
289
+ # This is a simplified implementation; for production use,
290
+ # consider using a proper image library like MiniMagick or ImageMagick.
291
+ #
292
+ # @param data [String] Binary image data.
293
+ # @param mime_type [String] Detected MIME type.
294
+ # @return [Hash] Hash containing width/height if detectable.
295
+ def extract_dimensions(data, mime_type)
296
+ case mime_type
297
+ when "image/png"
298
+ extract_png_dimensions(data)
299
+ when "image/jpeg"
300
+ extract_jpeg_dimensions(data)
301
+ when "image/gif"
302
+ extract_gif_dimensions(data)
303
+ else
304
+ {}
305
+ end
306
+ rescue StandardError
307
+ {} # Return empty hash if dimension extraction fails
308
+ end
309
+
310
+ private
311
+
312
+ # Extracts PNG dimensions from IHDR chunk.
313
+ def extract_png_dimensions(data)
314
+ return {} unless data.length > 24
315
+
316
+ # PNG IHDR chunk starts at byte 16 and contains width/height
317
+ width = data[16, 4].unpack1("N")
318
+ height = data[20, 4].unpack1("N")
319
+
320
+ { width: width, height: height }
321
+ end
322
+
323
+ # Extracts JPEG dimensions from SOF marker.
324
+ def extract_jpeg_dimensions(data)
325
+ # Simple JPEG dimension extraction
326
+ # Look for SOF0 (Start of Frame) marker
327
+ offset = 2
328
+ while offset < data.length - 8
329
+ marker = data[offset, 2].unpack1("n")
330
+ length = data[offset + 2, 2].unpack1("n")
331
+
332
+ # SOF0 marker (0xFFC0)
333
+ if marker == 0xFFC0
334
+ height = data[offset + 5, 2].unpack1("n")
335
+ width = data[offset + 7, 2].unpack1("n")
336
+ return { width: width, height: height }
337
+ end
338
+
339
+ offset += 2 + length
340
+ end
341
+
342
+ {}
343
+ end
344
+
345
+ # Extracts GIF dimensions from header.
346
+ def extract_gif_dimensions(data)
347
+ return {} unless data.length > 10
348
+
349
+ # GIF dimensions are at bytes 6-9
350
+ width = data[6, 2].unpack1("v") # Little-endian
351
+ height = data[8, 2].unpack1("v") # Little-endian
352
+
353
+ { width: width, height: height }
354
+ end
355
+
356
+ module_function :extract_dimensions, :extract_png_dimensions, :extract_jpeg_dimensions, :extract_gif_dimensions
357
+ end
358
+ end