remove_bg 1.2.0 → 1.5.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.
@@ -0,0 +1,11 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "faraday", "~> 0.16.0"
6
+
7
+ group :development do
8
+ gem "appraisal", git: "https://github.com/thoughtbot/appraisal.git", ref: "5868643"
9
+ end
10
+
11
+ gemspec path: "../"
@@ -0,0 +1,11 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "faraday", "~> 0.17.0"
6
+
7
+ group :development do
8
+ gem "appraisal", git: "https://github.com/thoughtbot/appraisal.git", ref: "5868643"
9
+ end
10
+
11
+ gemspec path: "../"
@@ -0,0 +1,11 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "faraday", "~> 1.0"
6
+
7
+ group :development do
8
+ gem "appraisal", git: "https://github.com/thoughtbot/appraisal.git", ref: "5868643"
9
+ end
10
+
11
+ gemspec path: "../"
@@ -4,16 +4,38 @@ require "remove_bg/configuration"
4
4
  require "remove_bg/request_options"
5
5
 
6
6
  module RemoveBg
7
+ # Removes the background from an image on the local file system
8
+ # @param image_path [String] Path to the input image
9
+ # @param options [Hash<Symbol, Object>] Image processing options (see API docs)
10
+ # @return [RemoveBg::Result|RemoveBg::CompositeResult] a processed image result
11
+ #
7
12
  def self.from_file(image_path, raw_options = {})
8
13
  options = RemoveBg::RequestOptions.new(raw_options)
9
14
  ApiClient.new.remove_from_file(image_path, options)
10
15
  end
11
16
 
17
+ # Removes the background from the image at the URL specified
18
+ # @param image_url [String] Absolute URL of the input image
19
+ # @param options [Hash<Symbol, Object>] Image processing options (see API docs)
20
+ # @return [RemoveBg::Result|RemoveBg::CompositeResult] A processed image result
21
+ #
12
22
  def self.from_url(image_url, raw_options = {})
13
23
  options = RemoveBg::RequestOptions.new(raw_options)
14
24
  ApiClient.new.remove_from_url(image_url, options)
15
25
  end
16
26
 
27
+ # Fetches account information for the globally configured API key, or a
28
+ # specific API key if provided
29
+ # @param options [Hash<Symbol, Object>]
30
+ # @return [RemoveBg::AccountInfo]
31
+ #
32
+ def self.account_info(raw_options = {})
33
+ options = RemoveBg::BaseRequestOptions.new(raw_options)
34
+ ApiClient.new.account_info(options)
35
+ end
36
+
37
+ # Yields the global Remove.bg configuration
38
+ # @yield [RemoveBg::Configuration]
17
39
  def self.configure
18
40
  yield RemoveBg::Configuration.configuration
19
41
  end
@@ -0,0 +1,34 @@
1
+ module RemoveBg
2
+ class AccountInfo
3
+ # @return [RemoveBg::AccountInfo::ApiInfo]
4
+ attr_reader :api
5
+
6
+ # @return [RemoveBg::AccountInfo::CreditsInfo]
7
+ attr_reader :credits
8
+
9
+ def initialize(attributes)
10
+ @api = ApiInfo.new(**attributes.fetch(:api))
11
+ @credits = CreditsInfo.new(**attributes.fetch(:credits))
12
+ end
13
+
14
+ class ApiInfo
15
+ attr_reader :free_calls, :sizes
16
+
17
+ def initialize(free_calls:, sizes:)
18
+ @free_calls = free_calls
19
+ @sizes = sizes
20
+ end
21
+ end
22
+
23
+ class CreditsInfo
24
+ attr_reader :total, :subscription, :payg, :enterprise
25
+
26
+ def initialize(total:, subscription:, payg:, enterprise:)
27
+ @total = total
28
+ @subscription = subscription
29
+ @payg = payg
30
+ @enterprise = enterprise
31
+ end
32
+ end
33
+ end
34
+ end
@@ -2,19 +2,13 @@ module RemoveBg
2
2
  module Api
3
3
  URL = "https://api.remove.bg"
4
4
 
5
+ V1_ACCOUNT = "/v1.0/account"
6
+ private_constant :V1_ACCOUNT
7
+
5
8
  V1_REMOVE_BG = "/v1.0/removebg"
6
9
  private_constant :V1_REMOVE_BG
7
10
 
8
11
  HEADER_API_KEY = "X-Api-Key"
9
12
  private_constant :HEADER_API_KEY
10
-
11
- HEADER_WIDTH = "X-Width"
12
- private_constant :HEADER_WIDTH
13
-
14
- HEADER_HEIGHT = "X-Height"
15
- private_constant :HEADER_HEIGHT
16
-
17
- HEADER_CREDITS_CHARGED = "X-Credits-Charged"
18
- private_constant :HEADER_CREDITS_CHARGED
19
13
  end
20
14
  end
@@ -1,7 +1,13 @@
1
1
  require "json"
2
+ require "tempfile"
3
+
4
+ require_relative "account_info"
2
5
  require_relative "api"
6
+ require_relative "composite_result"
3
7
  require_relative "error"
4
8
  require_relative "http_connection"
9
+ require_relative "rate_limit_info"
10
+ require_relative "result_metadata"
5
11
  require_relative "result"
6
12
  require_relative "upload"
7
13
  require_relative "url_validator"
@@ -10,59 +16,141 @@ module RemoveBg
10
16
  class ApiClient
11
17
  include RemoveBg::Api
12
18
 
19
+ # @param connection [Faraday::Connection]
20
+ #
13
21
  def initialize(connection: RemoveBg::HttpConnection.build)
14
22
  @connection = connection
15
23
  end
16
24
 
25
+ # Removes the background from an image on the local file system
26
+ # @param image_path [String]
27
+ # @param options [RemoveBg::RequestOptions]
28
+ # @return [RemoveBg::Result|RemoveBg::CompositeResult]
29
+ # @raise [RemoveBg::Error]
30
+ #
17
31
  def remove_from_file(image_path, options)
18
32
  data = options.data.merge(image_file: Upload.for_file(image_path))
19
33
  request_remove_bg(data, options.api_key)
20
34
  end
21
35
 
36
+ # Removes the background from the image at the URL specified
37
+ # @param image_url [String]
38
+ # @param options [RemoveBg::RequestOptions]
39
+ # @return [RemoveBg::Result|RemoveBg::CompositeResult]
40
+ # @raise [RemoveBg::Error]
41
+ #
22
42
  def remove_from_url(image_url, options)
23
43
  RemoveBg::UrlValidator.validate(image_url)
24
44
  data = options.data.merge(image_url: image_url)
25
45
  request_remove_bg(data, options.api_key)
26
46
  end
27
47
 
48
+ # Fetches account information
49
+ # @param options [RemoveBg::BaseRequestOptions]
50
+ # @return [RemoveBg::AccountInfo]
51
+ # @raise [RemoveBg::Error]
52
+ #
53
+ def account_info(options)
54
+ request_account_info(options.api_key)
55
+ end
56
+
28
57
  private
29
58
 
30
59
  attr_reader :connection
31
60
 
32
61
  def request_remove_bg(data, api_key)
62
+ download = Tempfile.new("remove-bg-download")
63
+ download.binmode
64
+
65
+ streaming = false
66
+
33
67
  response = connection.post(V1_REMOVE_BG, data) do |req|
34
68
  req.headers[HEADER_API_KEY] = api_key
69
+
70
+ # Faraday v0.16 & v1.0+ support streaming, v0.17 did not (rollback release)
71
+ if req.options.respond_to?(:on_data)
72
+ streaming = true
73
+ req.options.on_data = Proc.new do |chunk, _|
74
+ download.write(chunk)
75
+ end
76
+ end
77
+ end
78
+
79
+ # Faraday v0.15 / v0.17
80
+ if !streaming
81
+ download.write(response.body)
82
+ end
83
+
84
+ download.rewind
85
+
86
+ if response.status == 200
87
+ parse_image_result(headers: response.headers, download: download)
88
+ else
89
+ response_body = download.read
90
+ download.close
91
+ download.unlink
92
+ handle_http_error(response: response, body: response_body)
93
+ end
94
+ end
95
+
96
+ def request_account_info(api_key)
97
+ response = connection.get(V1_ACCOUNT) do |req|
98
+ req.headers[HEADER_API_KEY] = api_key
35
99
  end
36
100
 
101
+ if response.status == 200
102
+ parse_account_result(response)
103
+ else
104
+ handle_http_error(response: response, body: response.body)
105
+ end
106
+ end
107
+
108
+ def handle_http_error(response:, body:)
109
+ error_message = parse_error_message(body)
110
+
37
111
  case response.status
38
- when 200
39
- parse_result(response)
112
+ when 429
113
+ rate_limit = RateLimitInfo.new(response.headers)
114
+ raise RemoveBg::RateLimitError.new(error_message, response, body, rate_limit)
40
115
  when 400..499
41
- error_message = parse_error_message(response)
42
- raise RemoveBg::ClientHttpError.new(error_message, response)
116
+ raise RemoveBg::ClientHttpError.new(error_message, response, body)
43
117
  when 500..599
44
- error_message = parse_error_message(response)
45
- raise RemoveBg::ServerHttpError.new(error_message, response)
118
+ raise RemoveBg::ServerHttpError.new(error_message, response, body)
46
119
  else
47
- raise RemoveBg::HttpError.new("An unknown error occurred", response)
120
+ raise RemoveBg::HttpError.new("An unknown error occurred", response, body)
48
121
  end
49
122
  end
50
123
 
51
- def parse_result(response)
52
- RemoveBg::Result.new(
53
- data: response.body,
54
- width: response.headers[HEADER_WIDTH]&.to_i,
55
- height: response.headers[HEADER_HEIGHT]&.to_i,
56
- credits_charged: response.headers[HEADER_CREDITS_CHARGED]&.to_f,
124
+ def parse_image_result(headers:, download:)
125
+ result_for_content_type(headers["Content-Type"]).new(
126
+ download: download,
127
+ metadata: ResultMetadata.new(headers),
128
+ rate_limit: RateLimitInfo.new(headers)
57
129
  )
58
130
  end
59
131
 
60
- def parse_error_message(response)
61
- parse_errors(response).first["title"]
132
+ def result_for_content_type(content_type)
133
+ if content_type&.include?("application/zip")
134
+ CompositeResult
135
+ else
136
+ Result
137
+ end
138
+ end
139
+
140
+ def parse_account_result(response)
141
+ attributes = JSON.parse(response.body, symbolize_names: true)
142
+ .fetch(:data)
143
+ .fetch(:attributes)
144
+
145
+ RemoveBg::AccountInfo.new(attributes)
146
+ end
147
+
148
+ def parse_error_message(response_body)
149
+ parse_errors(response_body).first["title"]
62
150
  end
63
151
 
64
- def parse_errors(response)
65
- JSON.parse(response.body)["errors"] || []
152
+ def parse_errors(response_body)
153
+ JSON.parse(response_body)["errors"] || []
66
154
  rescue JSON::ParserError
67
155
  [{ "title" => "Unable to parse response" }]
68
156
  end
@@ -0,0 +1,32 @@
1
+ require_relative "error"
2
+
3
+ module RemoveBg
4
+ class BaseRequestOptions
5
+ attr_reader :api_key, :data
6
+
7
+ def initialize(raw_options = {})
8
+ options = raw_options.dup
9
+ @api_key = resolve_api_key(options.delete(:api_key))
10
+ @data = options
11
+ end
12
+
13
+ private
14
+
15
+ def resolve_api_key(request_api_key)
16
+ api_key = request_api_key || global_api_key
17
+
18
+ if api_key.nil? || api_key.empty?
19
+ raise RemoveBg::Error, <<~MSG
20
+ Please configure an API key or specify one per request. API key was:
21
+ #{api_key.inspect}
22
+ MSG
23
+ end
24
+
25
+ api_key
26
+ end
27
+
28
+ def global_api_key
29
+ RemoveBg::Configuration.configuration.api_key
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,65 @@
1
+ require_relative "result"
2
+
3
+ module RemoveBg
4
+ # Handles image composition for larger images (over 10MP) where transparency is
5
+ # required.
6
+ # @see RemoveBg::Result
7
+ #
8
+ class CompositeResult < Result
9
+ # Saves the ZIP archive containing the alpha.png and color.jpg files
10
+ # (useful if you want to handle composition yourself)
11
+ # @param file_path [string]
12
+ # @param overwrite [boolean] Overwrite any existing file at the specified path
13
+ # @return [nil]
14
+ #
15
+ def save_zip(file_path, overwrite: false)
16
+ if File.exist?(file_path) && !overwrite
17
+ raise FileOverwriteError.new(file_path)
18
+ end
19
+
20
+ FileUtils.cp(download, file_path)
21
+ end
22
+
23
+ private
24
+
25
+ def image_file
26
+ composite_file
27
+ end
28
+
29
+ def composite_file
30
+ @composite_file ||= begin
31
+ binary_tempfile(["composed", ".png"])
32
+ .tap { |file| compose_to_file(file) }
33
+ end
34
+ end
35
+
36
+ def color_file
37
+ @color_file ||= binary_tempfile(["color", ".jpg"])
38
+ end
39
+
40
+ def alpha_file
41
+ @alpha_file ||= binary_tempfile(["alpha", ".png"])
42
+ end
43
+
44
+ def compose_to_file(destination)
45
+ extract_parts
46
+
47
+ ImageComposer.new.compose(
48
+ color_file: color_file,
49
+ alpha_file: alpha_file,
50
+ destination_path: destination.path
51
+ )
52
+ end
53
+
54
+ def extract_parts
55
+ Zip::File.open(download) do |zf|
56
+ zf.find_entry("color.jpg").extract(color_file.path) { true }
57
+ zf.find_entry("alpha.png").extract(alpha_file.path) { true }
58
+ end
59
+ end
60
+
61
+ def binary_tempfile(basename)
62
+ Tempfile.new(basename).tap { |file| file.binmode }
63
+ end
64
+ end
65
+ end
@@ -1,13 +1,24 @@
1
+ require_relative "image_composer"
2
+
1
3
  module RemoveBg
2
4
  class Configuration
3
- attr_accessor :api_key
5
+ attr_accessor :api_key, :image_processor, :auto_upgrade_png_to_zip
4
6
 
5
7
  def self.configuration
6
- @configuration ||= Configuration.new
8
+ @configuration ||= Configuration.new.tap do |config|
9
+ config.image_processor = ImageComposer.detect_image_processor
10
+
11
+ # Upgrade to ZIP where possible to save bandwith
12
+ config.auto_upgrade_png_to_zip = true
13
+ end
7
14
  end
8
15
 
9
16
  def self.reset
10
- @configuration = Configuration.new
17
+ @configuration = nil
18
+ end
19
+
20
+ def can_process_images?
21
+ !image_processor.nil?
11
22
  end
12
23
  end
13
24
  end
@@ -2,17 +2,35 @@ module RemoveBg
2
2
  class Error < StandardError; end
3
3
 
4
4
  class HttpError < Error
5
+ # @return [Faraday::Response]
5
6
  attr_reader :http_response
6
7
 
7
- def initialize(message, http_response)
8
+ # @return [String]
9
+ attr_reader :http_response_body
10
+
11
+ def initialize(message, http_response, http_response_body)
8
12
  @http_response = http_response
13
+ @http_response_body = http_response_body
9
14
  super(message)
10
15
  end
11
16
  end
12
17
 
18
+ # Raised for all HTTP 4XX errors
13
19
  class ClientHttpError < HttpError; end
20
+
21
+ # Raised for all HTTP 5XX errors
14
22
  class ServerHttpError < HttpError; end
15
23
 
24
+ # Raised for HTTP 429 status code
25
+ class RateLimitError < ClientHttpError
26
+ attr_reader :rate_limit
27
+
28
+ def initialize(message, http_response, http_response_body, rate_limit)
29
+ @rate_limit = rate_limit
30
+ super(message, http_response, http_response_body)
31
+ end
32
+ end
33
+
16
34
  class FileError < Error
17
35
  attr_reader :file_path
18
36