bynn-decisionnode 0.4.1

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.
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ class DecisionNode
6
+ # A document for a knowledge base (a collection of type "text"): the
7
+ # content of one item.
8
+ #
9
+ # DecisionNode::Document.new(text: "# Receipts\n\nReject any claim ...") # Markdown inline
10
+ # DecisionNode::Document.new(text: "Plain words.", format: "text")
11
+ # DecisionNode::Document.new(path: "claims-policy-2026.pdf")
12
+ # DecisionNode::Document.new(data: docx_bytes, filename: "rules.docx")
13
+ # DecisionNode::Document.new(io: $stdin, content_type: "text/markdown")
14
+ # DecisionNode::Document.new(upload_id: "upl_...") # from #upload_documents
15
+ #
16
+ # A text is sent inline, whatever its size. A file (a path, bytes or an IO)
17
+ # is read when the call is made: a text or Markdown file whose bytes total
18
+ # under the client's inline threshold and are UTF-8 without a NUL is sent
19
+ # inline as its text; every other file is uploaded first and named by its
20
+ # upload id. An IO is read to its end once, into memory, before anything is
21
+ # sent.
22
+ #
23
+ # content_type:: one of CONTENT_TYPES. Left out, it comes from the file
24
+ # name's extension, else from the first bytes (PDF, PNG, JPEG,
25
+ # TIFF, WebP). DOCX, PPTX, XLSX and text cannot be told from
26
+ # their bytes: they need content_type or a file name.
27
+ # filename:: a path's base name unless given; optional for bytes and IOs.
28
+ # Its stem is the item's title when none is given.
29
+ class Document
30
+ # The types a knowledge file may have, in the API's order.
31
+ CONTENT_TYPES = %w[
32
+ text/plain text/markdown application/pdf
33
+ application/vnd.openxmlformats-officedocument.wordprocessingml.document
34
+ application/vnd.openxmlformats-officedocument.presentationml.presentation
35
+ application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
36
+ text/html image/png image/jpeg image/tiff image/webp
37
+ ].freeze
38
+
39
+ # The most bytes a knowledge file may have (50 MB).
40
+ MAX_BYTES = 52_428_800
41
+
42
+ FORMATS = %w[markdown text].freeze
43
+
44
+ EXTENSIONS = {
45
+ ".txt" => "text/plain", ".md" => "text/markdown", ".markdown" => "text/markdown",
46
+ ".pdf" => "application/pdf",
47
+ ".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
48
+ ".pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
49
+ ".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
50
+ ".html" => "text/html", ".htm" => "text/html", ".png" => "image/png", ".jpg" => "image/jpeg",
51
+ ".jpeg" => "image/jpeg", ".tif" => "image/tiff", ".tiff" => "image/tiff", ".webp" => "image/webp"
52
+ }.freeze
53
+ private_constant :EXTENSIONS
54
+
55
+ # What a call sends for a document, once its bytes are read: `kind` :text
56
+ # (inline), :file (to upload) or :upload (an upload id given).
57
+ Loaded = Struct.new(:kind, :text, :format, :data, :content_type, :filename, :upload_id) do
58
+ def md5
59
+ Digest::MD5.digest(data)
60
+ end
61
+
62
+ def sha256
63
+ Digest::SHA256.digest(data)
64
+ end
65
+ end
66
+
67
+ attr_reader :text, :format, :path, :data, :io, :content_type, :filename, :upload_id
68
+
69
+ def initialize(text: nil, format: nil, path: nil, data: nil, io: nil, upload_id: nil, content_type: nil,
70
+ filename: nil)
71
+ sources = [text, path, data, io, upload_id].compact.size
72
+ raise ArgumentError, "give exactly one of text:, path:, data:, io: or upload_id:" unless sources == 1
73
+ raise ArgumentError, "text: must be a String" unless text.nil? || text.is_a?(String)
74
+ raise ArgumentError, "data: must be a String of bytes" unless data.nil? || data.is_a?(String)
75
+ raise ArgumentError, "io: must respond to read" unless io.nil? || io.respond_to?(:read)
76
+ raise ArgumentError, "format: goes with text:; a file's format comes from its type" unless format.nil? || !text.nil?
77
+ if text || upload_id
78
+ raise ArgumentError, "content_type: and filename: go with a file" unless content_type.nil? && filename.nil?
79
+ end
80
+
81
+ @text = text
82
+ @format = format.is_a?(Symbol) ? format.to_s : format
83
+ @path = path&.to_s
84
+ @data = data
85
+ @io = io
86
+ @upload_id = upload_id&.to_s
87
+ @content_type = content_type.nil? || content_type.to_s.empty? ? nil : content_type.to_s
88
+ @filename = filename.nil? ? @path && File.basename(@path) : filename.to_s
89
+ end
90
+
91
+ def inspect
92
+ what = if @text then "text"
93
+ elsif @upload_id then "upload_id=#{@upload_id}"
94
+ else "file#{@filename ? " #{@filename.inspect}" : ''}"
95
+ end
96
+ "#<#{self.class.name} #{what}>"
97
+ end
98
+
99
+ # True for a text given in memory.
100
+ def text?
101
+ !@text.nil?
102
+ end
103
+
104
+ # True for a file (a path, bytes or an IO).
105
+ def file?
106
+ @text.nil? && @upload_id.nil?
107
+ end
108
+
109
+ # The file's bytes and type, read now, checked as the presign checks them
110
+ # (`index` is the file's place in it). A text and an upload id come back
111
+ # as they are.
112
+ def load(index = 0)
113
+ return Loaded.new(:text, @text, @format) if @text
114
+ return Loaded.new(:upload, nil, nil, nil, nil, nil, @upload_id) if @upload_id
115
+
116
+ bytes = read_bytes
117
+ type = @content_type || self.class.type_of(@filename, bytes)
118
+ if type.nil?
119
+ raise ArgumentError, "the document's type cannot be told from its name or its first bytes: give content_type: or filename:"
120
+ end
121
+ unless CONTENT_TYPES.include?(type)
122
+ usage("files[#{index}].content_type: must be one of #{CONTENT_TYPES.join(', ')}, got #{type}.")
123
+ end
124
+ usage("files[#{index}].byte_size: must be an integer from 1 to #{MAX_BYTES}.") if bytes.empty?
125
+ if bytes.bytesize > MAX_BYTES
126
+ usage("files[#{index}].byte_size: #{bytes.bytesize} is over the limit of #{MAX_BYTES} bytes.")
127
+ end
128
+
129
+ Loaded.new(:file, nil, nil, bytes.b.freeze, type, @filename)
130
+ end
131
+
132
+ # The type of a file: from its name's extension, else from its first bytes;
133
+ # nil when neither tells.
134
+ def self.type_of(filename, bytes)
135
+ ext = filename && File.extname(filename).downcase
136
+ return EXTENSIONS[ext] if ext && EXTENSIONS.key?(ext)
137
+
138
+ head = bytes.byteslice(0, 12).b
139
+ if head.start_with?("%PDF-") then "application/pdf"
140
+ elsif head.start_with?("\x89PNG\r\n\x1A\n".b) then "image/png"
141
+ elsif head.start_with?("\xFF\xD8\xFF".b) then "image/jpeg"
142
+ elsif head.start_with?("II*\0".b, "MM\0*".b) then "image/tiff"
143
+ elsif head.start_with?("RIFF") && head.byteslice(8, 4) == "WEBP" then "image/webp"
144
+ end
145
+ end
146
+
147
+ # The stem of a file name: the name without its last extension.
148
+ def self.stem(filename)
149
+ base = File.basename(filename.to_s)
150
+ ext = File.extname(base)
151
+ ext.empty? ? base : base.delete_suffix(ext)
152
+ end
153
+
154
+ private
155
+
156
+ # The file's bytes; a path or an IO is read to its end, or to one byte
157
+ # past the limit, which the size check then refuses.
158
+ def read_bytes
159
+ return @data if @data
160
+ return read_io(@io) if @io
161
+
162
+ File.open(@path, "rb") { |f| read_io(f) }
163
+ rescue SystemCallError, IOError => e
164
+ raise ArgumentError, "cannot read #{@path}: #{e.message}"
165
+ end
166
+
167
+ def read_io(io)
168
+ out = +"".b
169
+ while out.bytesize <= MAX_BYTES && (chunk = io.read([1 << 20, MAX_BYTES + 1 - out.bytesize].min))
170
+ out << chunk.b
171
+ end
172
+ out
173
+ end
174
+
175
+ def usage(message)
176
+ raise BadRequestError.new(message, error_type: "api_usage_error")
177
+ end
178
+ end
179
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ class DecisionNode
4
+ # The base of every error this gem raises, and the class of an error for a
5
+ # status no subclass covers (a 3xx, 405, 409, or 408 once its retries are spent).
6
+ #
7
+ # The error's own `message` is the server's message (`detail.message`, or
8
+ # `detail` when it is a string), or the client's description when there was
9
+ # no response. It is an empty string when the server sent none. `reason` is
10
+ # the server's explanation in words, from the `x-decisionnode-error` header.
11
+ # `boosters` is the `x-decisionnode-boosters` header of a request that
12
+ # named boosters, as booster name to BoosterStatus in the request's order
13
+ # (an empty Hash when there is none), so a caller can see which required
14
+ # booster failed. `details` is the error body's `detail.details` when it is
15
+ # an object (an enrollment's 529 says why in its `reason`), else nil. The
16
+ # report Ruby prints for an uncaught error adds the class, status and
17
+ # reason.
18
+ class DecisionNodeError < StandardError
19
+ include DecisionResult
20
+
21
+ attr_reader :status, :error_type, :request_id, :reason, :body, :headers, :boosters, :details
22
+
23
+ def initialize(message = nil, status: nil, error_type: nil, request_id: nil, reason: nil, body: nil, headers: nil)
24
+ @status = status
25
+ @error_type = error_type
26
+ @request_id = request_id
27
+ @reason = reason
28
+ @body = body
29
+ @headers = headers || {}
30
+ @boosters = BoosterStatus.parse(@headers["x-decisionnode-boosters"])
31
+ detail = body.is_a?(Hash) ? body["detail"] : nil
32
+ details = detail.is_a?(Hash) ? detail["details"] : nil
33
+ @details = details.is_a?(Hash) ? details : nil
34
+ super(message.nil? ? "" : message.to_s)
35
+ end
36
+
37
+ def detailed_message(highlight: false, **)
38
+ parts = []
39
+ parts << message unless message.empty?
40
+ parts << "HTTP #{status}" if status
41
+ parts << "error_type: #{error_type}" if error_type
42
+ parts << "reason: #{reason}" if reason
43
+ parts << "request_id: #{request_id}" if request_id
44
+ "#{parts.empty? ? 'DecisionNode error' : parts.join('; ')} (#{self.class.name})"
45
+ end
46
+ end
47
+
48
+ # 400: the request is malformed or breaks a limit of the model. Also raised
49
+ # before any request when the SDK finds a fault the API would answer with a
50
+ # 400 (an image, an option or level count): such an error has no status and
51
+ # no request id.
52
+ class BadRequestError < DecisionNodeError; end
53
+
54
+ # 400 with `error_type` `max_tokens_exceeded`: the input is too long for the model.
55
+ class ContextLimitError < BadRequestError; end
56
+
57
+ # 401: the API key is missing or not valid.
58
+ class AuthenticationError < DecisionNodeError; end
59
+
60
+ # 402: the account has no credit left.
61
+ class InsufficientCreditError < DecisionNodeError; end
62
+
63
+ # 403 that is not a refusal: the workspace may not do this, for example use
64
+ # a booster turned off for it (`booster_not_enabled`), or it is suspended.
65
+ # `error_type` tells them apart.
66
+ class PermissionDeniedError < DecisionNodeError; end
67
+
68
+ # 403 with `error_type` `refusal_error`: the request was refused under the usage policy.
69
+ class RefusalError < DecisionNodeError
70
+ # "harm" or "self_harm".
71
+ attr_reader :category
72
+ # The parsed `x-decisionnode-safety` header, a Safety or nil.
73
+ attr_reader :safety
74
+
75
+ def initialize(message = nil, category: nil, safety: nil, **kw)
76
+ @category = category
77
+ @safety = safety
78
+ super(message, **kw)
79
+ end
80
+ end
81
+
82
+ # 404: the path or resource does not exist.
83
+ class NotFoundError < DecisionNodeError; end
84
+
85
+ # 413: the request body is over the API's limit of 26,214,400 bytes (25
86
+ # MiB). Never retried. Also raised before any request when the encoded body
87
+ # would be over it: such an error has no status and no request id.
88
+ class RequestTooLargeError < DecisionNodeError; end
89
+
90
+ # 422: the request body does not match the schema. Also raised before any
91
+ # request for a number grid the API would refuse, or a score with no levels.
92
+ class ValidationError < DecisionNodeError
93
+ # The server's list of validation entries.
94
+ attr_reader :entries
95
+
96
+ def initialize(message = nil, entries: nil, **kw)
97
+ @entries = entries
98
+ super(message, **kw)
99
+ end
100
+ end
101
+
102
+ # 429: too many requests.
103
+ class RateLimitError < DecisionNodeError
104
+ # Seconds to wait before trying again, or nil.
105
+ attr_reader :retry_after
106
+
107
+ def initialize(message = nil, retry_after: nil, **kw)
108
+ @retry_after = retry_after
109
+ super(message, **kw)
110
+ end
111
+ end
112
+
113
+ # 429 with `error_type` `session_limit_exceeded`: the key already holds as
114
+ # many open sessions as it may. Never retried.
115
+ class SessionLimitError < RateLimitError; end
116
+
117
+ # 529: the service is overloaded, or (`error_type` `booster_unavailable`) a
118
+ # booster the request required failed or did not answer in time. Retried
119
+ # like every 529.
120
+ class OverloadedError < DecisionNodeError
121
+ # Seconds to wait before trying again, or nil.
122
+ attr_reader :retry_after
123
+
124
+ def initialize(message = nil, retry_after: nil, **kw)
125
+ @retry_after = retry_after
126
+ super(message, **kw)
127
+ end
128
+ end
129
+
130
+ # Any other 5xx, or a successful status with a body that is not JSON.
131
+ class ServerError < DecisionNodeError; end
132
+
133
+ # No response: the connection failed or was dropped.
134
+ class ConnectionError < DecisionNodeError; end
135
+
136
+ # No response within the timeout.
137
+ class TimeoutError < DecisionNodeError; end
138
+ end
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ class DecisionNode
6
+ # Local images whose bytes total less than this go inline as base64 `data`;
7
+ # at or above it they are uploaded once and sent by `upload_id`. Counted over
8
+ # all the local images of one request, one session frame or one batch line.
9
+ # The client's `inline_threshold:` option overrides it.
10
+ DEFAULT_INLINE_THRESHOLD = 524_288
11
+
12
+ # An image sent with a decision.
13
+ #
14
+ # DecisionNode::Image.new("scan", url: "https://example.com/scan.webp") # recommended
15
+ # DecisionNode::Image.new("receipt", path: "receipt.png")
16
+ # DecisionNode::Image.new("photo", data: jpeg_bytes)
17
+ # DecisionNode::Image.new("photo", data: File.open("photo.jpg", "rb"))
18
+ # DecisionNode::Image.new("photo", base64: "/9j/4AAQ...")
19
+ # DecisionNode::Image.new("receipt", upload_id: "upl_...")
20
+ #
21
+ # A URL is sent as it is and fetched by the API, so nothing is uploaded: the
22
+ # fastest form. A file, an IO, bytes or base64 text are read here, when the
23
+ # Image is made; `data:` also takes base64 text and decodes it when the text
24
+ # is not already image bytes. The bytes are checked locally before any
25
+ # request (format from the magic bytes, at most 10,485,760 bytes, at most
26
+ # 40,000,000 pixels read from the header), then sent inline or uploaded by
27
+ # the client's inline threshold. An image is never resized or re-encoded.
28
+ #
29
+ # The id is unique within a request. The model sees the image as
30
+ # "Picture <n>" (its place in the request's images, from 1) with this id
31
+ # beside it, so text in the state or the instructions can refer to it by
32
+ # either.
33
+ #
34
+ # An unreadable file or an argument of the wrong type raises ArgumentError
35
+ # here; a fault in the image itself raises BadRequestError when it is sent,
36
+ # before any request.
37
+ class Image
38
+ # The API's limits on one image, checked before any request.
39
+ MAX_BYTES = 10_485_760
40
+ MAX_PIXELS = 40_000_000
41
+ MAX_URL_CHARS = 2_048
42
+ MEDIA_TYPES = %w[image/jpeg image/png image/webp].freeze
43
+
44
+ attr_reader :id, :url, :upload_id
45
+
46
+ # The image's bytes (a frozen binary String), or nil for a URL or an upload id.
47
+ attr_reader :data
48
+
49
+ # "image/jpeg", "image/png" or "image/webp" from the magic bytes, or the
50
+ # media type given when the bytes are none of these.
51
+ def self.sniff_media_type(bytes)
52
+ ImageHeader.media_type(bytes.b)
53
+ end
54
+
55
+ def initialize(id, url: nil, path: nil, data: nil, base64: nil, upload_id: nil, media_type: nil)
56
+ sources = [url, path, data, base64, upload_id].compact.size
57
+ raise ArgumentError, "give exactly one of url:, path:, data:, base64: or upload_id:" unless sources == 1
58
+
59
+ @id = id.to_s
60
+ @declared = media_type.nil? || media_type.to_s.empty? ? nil : media_type.to_s
61
+ @url = url
62
+ @upload_id = upload_id&.to_s
63
+ @data = read_bytes(path, data, base64)&.freeze
64
+ @media_type = @data ? self.class.sniff_media_type(@data) || @declared : @declared
65
+ @data_type = @data ? self.class.sniff_media_type(@data) : nil
66
+ @lock = Mutex.new
67
+ end
68
+
69
+ # The image's media type: detected from the bytes, else as given.
70
+ attr_reader :media_type
71
+
72
+ def local?
73
+ !@data.nil?
74
+ end
75
+
76
+ def bytesize
77
+ @data ? @data.bytesize : 0
78
+ end
79
+
80
+ # [width, height] read from the header, or nil.
81
+ def dimensions
82
+ return nil unless @data && @data_type
83
+
84
+ @lock.synchronize do
85
+ @dimensions = ImageHeader.dimensions(@data, @data_type) unless defined?(@dimensions)
86
+ @dimensions
87
+ end
88
+ end
89
+
90
+ # The SHA-256 of the bytes, raw.
91
+ def sha256
92
+ @sha256 ||= Digest::SHA256.digest(@data)
93
+ end
94
+
95
+ # The MD5 of the bytes, raw.
96
+ def md5
97
+ @md5 ||= Digest::MD5.digest(@data)
98
+ end
99
+
100
+ # Raises BadRequestError (`api_usage_error`), as the API would answer,
101
+ # when the image's bytes break one of the API's rules: the format from the
102
+ # magic bytes (and a declared media type that differs), the size, and the
103
+ # pixel count read from the header. Returns self otherwise. A URL or an
104
+ # upload id is checked with the request (Checks.images!).
105
+ def validate!(max_bytes: MAX_BYTES)
106
+ fault = find_fault(max_bytes)
107
+ raise BadRequestError.new(fault, error_type: "api_usage_error") if fault
108
+
109
+ self
110
+ end
111
+
112
+ # The image's wire form with its bytes inline, or its URL or upload id.
113
+ def to_h
114
+ return url_wire if @url
115
+ return upload_wire(@upload_id) if @upload_id
116
+
117
+ { "id" => id, "media_type" => media_type, "data" => [@data].pack("m0") }
118
+ end
119
+
120
+ # The wire form naming an upload of these bytes.
121
+ def upload_wire(upload_id)
122
+ wire = { "id" => id, "upload_id" => upload_id }
123
+ wire["media_type"] = media_type if @upload_id && media_type
124
+ wire
125
+ end
126
+
127
+ def inspect
128
+ source = if @url then "url=#{@url.inspect}"
129
+ elsif @upload_id then "upload_id=#{@upload_id.inspect}"
130
+ else "#{media_type} #{bytesize} bytes"
131
+ end
132
+ "#<#{self.class.name} #{id.inspect} #{source}>"
133
+ end
134
+
135
+ private
136
+
137
+ def url_wire
138
+ wire = { "id" => id, "url" => @url }
139
+ wire["media_type"] = @declared if @declared
140
+ wire
141
+ end
142
+
143
+ def read_bytes(path, data, base64)
144
+ if path
145
+ begin
146
+ File.binread(path.to_s)
147
+ rescue SystemCallError, IOError => e
148
+ raise ArgumentError, "image #{@id.inspect}: cannot read #{path}: #{e.message}"
149
+ end
150
+ elsif base64
151
+ decode_base64(base64.to_s) || raise(ArgumentError, "image #{@id.inspect}: base64: is not base64 text")
152
+ elsif data.respond_to?(:read)
153
+ data.read.to_s.b
154
+ elsif data
155
+ raise ArgumentError, "image #{@id.inspect}: data must be a String of bytes or base64 text, or an IO" unless data.is_a?(String)
156
+
157
+ bytes = data.b
158
+ if ImageHeader.media_type(bytes).nil? && (decoded = decode_base64(bytes))
159
+ ImageHeader.media_type(decoded) ? decoded : bytes
160
+ else
161
+ bytes
162
+ end
163
+ end
164
+ end
165
+
166
+ # The decoded bytes of base64 text (line breaks allowed), or nil.
167
+ def decode_base64(text)
168
+ text.delete("\r\n").unpack1("m0").b
169
+ rescue ArgumentError
170
+ nil
171
+ end
172
+
173
+ # The first rule the bytes break, or nil. The pixel size is read once.
174
+ def find_fault(max_bytes)
175
+ return nil unless @data
176
+
177
+ found = @data_type
178
+ return "Image #{@id} is not a JPEG, PNG or WebP image." if found.nil?
179
+ return "Image #{@id} could not be decoded as #{@declared}." if @declared && @declared != found
180
+ return "Image #{@id} is larger than #{max_bytes / 1_048_576} MB." if @data.bytesize > max_bytes
181
+
182
+ size = @lock.synchronize do
183
+ @dimensions = ImageHeader.dimensions(@data, found) unless defined?(@dimensions)
184
+ @dimensions
185
+ end
186
+ return "Image #{@id}: its header cannot be read." if size.nil?
187
+
188
+ width, height = size
189
+ return "Image #{@id} is #{width}x#{height} pixels, over 40 megapixels." if width * height > MAX_PIXELS
190
+
191
+ nil
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ class DecisionNode
4
+ # Reads an image's format and pixel size from its first bytes, without
5
+ # decoding it: the JPEG start-of-frame segment, the PNG IHDR chunk and the
6
+ # WebP VP8, VP8L or VP8X header.
7
+ #
8
+ # DecisionNode::ImageHeader.media_type(bytes) # => "image/png"
9
+ # DecisionNode::ImageHeader.dimensions(bytes, "image/png") # => [640, 480]
10
+ module ImageHeader
11
+ module_function
12
+
13
+ PNG_SIGNATURE = "\x89PNG\r\n\x1A\n".b.freeze
14
+ JPEG_SIGNATURE = "\xFF\xD8\xFF".b.freeze
15
+ RIFF = "RIFF".b.freeze
16
+ WEBP = "WEBP".b.freeze
17
+ IHDR = "IHDR".b.freeze
18
+ VP8_START_CODE = "\x9D\x01\x2A".b.freeze
19
+
20
+ # JPEG start-of-frame markers that carry the frame size: SOF0 to SOF15
21
+ # except DHT (C4), JPG (C8) and DAC (CC).
22
+ JPEG_SOF = ([0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF]).freeze
23
+ # Markers that stand alone, with no length: TEM, RST0 to RST7, SOI.
24
+ JPEG_STANDALONE = ([0x01, 0xD8] + (0xD0..0xD7).to_a).freeze
25
+ JPEG_SOS = 0xDA
26
+ JPEG_EOI = 0xD9
27
+
28
+ # "image/jpeg", "image/png" or "image/webp" from the magic bytes, else nil.
29
+ def media_type(bytes)
30
+ return "image/jpeg" if bytes.start_with?(JPEG_SIGNATURE)
31
+ return "image/png" if bytes.start_with?(PNG_SIGNATURE)
32
+ return "image/webp" if bytes.start_with?(RIFF) && bytes.byteslice(8, 4) == WEBP
33
+
34
+ nil
35
+ end
36
+
37
+ # [width, height] in pixels read from the header, or nil when the header
38
+ # is missing, truncated or not of this type.
39
+ def dimensions(bytes, media_type)
40
+ case media_type
41
+ when "image/png" then png(bytes)
42
+ when "image/jpeg" then jpeg(bytes)
43
+ when "image/webp" then webp(bytes)
44
+ end
45
+ end
46
+
47
+ # The IHDR chunk is the first chunk: length (4), "IHDR", width (4), height (4).
48
+ def png(bytes)
49
+ return nil unless bytes.byteslice(12, 4) == IHDR
50
+
51
+ size = bytes.byteslice(16, 8)
52
+ size&.bytesize == 8 ? size.unpack("NN") : nil
53
+ end
54
+
55
+ # Walks the segments after SOI to the first start-of-frame segment:
56
+ # FF marker, length (2, counting itself), precision (1), height (2), width (2).
57
+ def jpeg(bytes)
58
+ i = 2
59
+ size = bytes.bytesize
60
+ while i + 1 < size
61
+ return nil unless bytes.getbyte(i) == 0xFF
62
+
63
+ marker = bytes.getbyte(i + 1)
64
+ if marker == 0xFF # fill byte before a marker
65
+ i += 1
66
+ next
67
+ end
68
+ if JPEG_STANDALONE.include?(marker)
69
+ i += 2
70
+ next
71
+ end
72
+ return nil if marker == JPEG_SOS || marker == JPEG_EOI || i + 4 > size
73
+
74
+ length = bytes.byteslice(i + 2, 2).unpack1("n")
75
+ return nil if length < 2
76
+
77
+ if JPEG_SOF.include?(marker)
78
+ frame = bytes.byteslice(i + 5, 4)
79
+ return nil unless frame&.bytesize == 4
80
+
81
+ height, width = frame.unpack("nn")
82
+ return [width, height]
83
+ end
84
+ i += 2 + length
85
+ end
86
+ nil
87
+ end
88
+
89
+ # RIFF (4), size (4), "WEBP", then the first chunk's fourcc at 12.
90
+ def webp(bytes)
91
+ case bytes.byteslice(12, 4)
92
+ when "VP8 ".b
93
+ # Frame tag (3) at 20, start code (3) at 23, then 14-bit width and height.
94
+ return nil unless bytes.byteslice(23, 3) == VP8_START_CODE
95
+
96
+ size = bytes.byteslice(26, 4)
97
+ return nil unless size&.bytesize == 4
98
+
99
+ width, height = size.unpack("vv")
100
+ [width & 0x3FFF, height & 0x3FFF]
101
+ when "VP8L".b
102
+ # Signature 0x2F at 20, then width - 1 and height - 1 in 14 bits each.
103
+ return nil unless bytes.getbyte(20) == 0x2F
104
+
105
+ bits = bytes.byteslice(21, 4)
106
+ return nil unless bits&.bytesize == 4
107
+
108
+ value = bits.unpack1("V")
109
+ [(value & 0x3FFF) + 1, ((value >> 14) & 0x3FFF) + 1]
110
+ when "VP8X".b
111
+ # Flags (4) at 20, then canvas width - 1 and height - 1 in 24 bits each.
112
+ canvas = bytes.byteslice(24, 6)
113
+ return nil unless canvas&.bytesize == 6
114
+
115
+ w = canvas.getbyte(0) | (canvas.getbyte(1) << 8) | (canvas.getbyte(2) << 16)
116
+ h = canvas.getbyte(3) | (canvas.getbyte(4) << 8) | (canvas.getbyte(5) << 16)
117
+ [w + 1, h + 1]
118
+ end
119
+ end
120
+ end
121
+ end