selectpdf 1.3.0 → 1.6.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.
data/lib/selectpdf.rb CHANGED
@@ -5,16 +5,175 @@ require 'json'
5
5
  require 'fileutils'
6
6
 
7
7
  #
8
- # SelectPdf Online REST API Ruby client library. Contains a powerful HTML to PDF converter.
8
+ # SelectPdf Online REST API Ruby client library. Contains HTML to PDF converter, PDF merge, PDF to text extractor, search PDF.
9
9
  #
10
10
  #
11
11
  # Convert HTML to PDF
12
12
  #
13
- # {include:file:samples/simple_url_to_pdf.rb}
13
+ # require 'selectpdf'
14
+ # print "This is SelectPdf-#{SelectPdf::CLIENT_VERSION}\n"
15
+ #
16
+ # url = 'https://selectpdf.com'
17
+ # local_file = 'Test.pdf'
18
+ # api_key = 'Your API key here'
19
+ #
20
+ # begin
21
+ # api = SelectPdf::HtmlToPdfClient.new(api_key)
22
+ #
23
+ # api.page_size = SelectPdf::PageSize::A4
24
+ # api.margins = 0
25
+ # api.page_numbers = false
26
+ # api.page_breaks_enhanced_algorithm = true
27
+ #
28
+ # api.convert_url_to_file(url, local_file)
29
+ # rescue SelectPdf::ApiException => e
30
+ # print("An error occurred: #{e}")
31
+ # end
32
+ #
33
+ # Merge PDFs from local disk or public url and save result into a file on disk.
34
+ #
35
+ # require 'selectpdf'
36
+ #
37
+ # $stdout.sync = true
38
+ #
39
+ # print "This is SelectPdf-#{SelectPdf::CLIENT_VERSION}\n"
40
+ #
41
+ # test_url = 'https://selectpdf.com/demo/files/selectpdf.pdf'
42
+ # test_pdf = 'Input.pdf'
43
+ # local_file = 'Result.pdf'
44
+ # api_key = 'Your API key here'
45
+ #
46
+ # begin
47
+ # client = SelectPdf::PdfMergeClient.new(api_key)
48
+ #
49
+ # # set parameters - see full list at https://selectpdf.com/pdf-merge-api/
50
+ #
51
+ # # specify the pdf files that will be merged (order will be preserved in the final pdf)
52
+ # client.add_file(test_pdf) # add PDF from local file
53
+ # client.add_url_file(test_url) # add PDF from public url
54
+ # # client.add_file(test_pdf, 'pdf_password') # add PDF (that requires a password) from local file
55
+ # # client.add_url_file(test_url, 'pdf_password') # add PDF (that requires a password) from public url
56
+ #
57
+ # print "Starting pdf merge ...\n"
58
+ #
59
+ # # merge pdfs to local file
60
+ # client.save_to_file(local_file)
61
+ #
62
+ # # merge pdfs to memory
63
+ # # pdf = client.save
64
+ #
65
+ # print "Finished! Number of pages: #{client.number_of_pages}.\n"
66
+ #
67
+ # # get API usage
68
+ # usage_client = SelectPdf::UsageClient.new(api_key)
69
+ # usage = usage_client.get_usage(false)
70
+ # print("Usage: #{usage}\n")
71
+ # print('Conversions remained this month: ', usage['available'], "\n")
72
+ # rescue SelectPdf::ApiException => e
73
+ # print("An error occurred: #{e}")
74
+ # end
75
+ #
76
+ # Extract text from PDF
77
+ #
78
+ # require 'selectpdf'
79
+ #
80
+ # $stdout.sync = true
81
+ #
82
+ # print "This is SelectPdf-#{SelectPdf::CLIENT_VERSION}\n"
83
+ #
84
+ # test_url = 'https://selectpdf.com/demo/files/selectpdf.pdf'
85
+ # test_pdf = 'Input.pdf'
86
+ # local_file = 'Result.txt'
87
+ # api_key = 'Your API key here'
88
+ #
89
+ # begin
90
+ # client = SelectPdf::PdfToTextClient.new(api_key)
91
+ #
92
+ # # set parameters - see full list at https://selectpdf.com/pdf-to-text-api/
93
+ # client.start_page = 1 # start page (processing starts from here)
94
+ # client.end_page = 0 # end page (set 0 to process file til the end)
95
+ # client.output_format = SelectPdf::OutputFormat::TEXT # set output format (Text or HTML)
96
+ #
97
+ # print "Starting pdf to text ...\n"
98
+ #
99
+ # # convert local pdf to local text file
100
+ # client.text_from_file_to_file(test_pdf, local_file)
101
+ #
102
+ # # extract text from local pdf to memory
103
+ # # text = client.text_from_file(test_pdf)
104
+ # # print text
105
+ #
106
+ # # convert pdf from public url to local text file
107
+ # # client.text_from_url_to_file(test_url, local_file)
108
+ #
109
+ # # extract text from pdf from public url to memory
110
+ # # text = client.text_from_url(test_url)
111
+ # # print text
112
+ #
113
+ # print "Finished! Number of pages processed: #{client.number_of_pages}.\n"
114
+ #
115
+ # # get API usage
116
+ # usage_client = SelectPdf::UsageClient.new(api_key)
117
+ # usage = usage_client.get_usage(false)
118
+ # print("Usage: #{usage}\n")
119
+ # print('Conversions remained this month: ', usage['available'], "\n")
120
+ # rescue SelectPdf::ApiException => e
121
+ # print("An error occurred: #{e}")
122
+ # end
123
+ #
124
+ # Search Pdf
125
+ #
126
+ # require 'selectpdf'
127
+ #
128
+ # $stdout.sync = true
129
+ #
130
+ # print "This is SelectPdf-#{SelectPdf::CLIENT_VERSION}\n"
131
+ #
132
+ # test_url = 'https://selectpdf.com/demo/files/selectpdf.pdf'
133
+ # test_pdf = 'Input.pdf'
134
+ # api_key = 'Your API key here'
135
+ #
136
+ # begin
137
+ # client = SelectPdf::PdfToTextClient.new(api_key)
138
+ #
139
+ # # set parameters - see full list at https://selectpdf.com/pdf-to-text-api/
140
+ # client.start_page = 1 # start page (processing starts from here)
141
+ # client.end_page = 0 # end page (set 0 to process file til the end)
142
+ # client.output_format = SelectPdf::OutputFormat::TEXT # set output format (Text or HTML)
143
+ #
144
+ # print "Starting search pdf ...\n"
145
+ #
146
+ # # search local pdf
147
+ # results = client.search_file(test_pdf, 'pdf')
148
+ #
149
+ # # search pdf from public url
150
+ # # results = client.search_url(test_url, 'pdf')
151
+ #
152
+ # print "Search results: #{results}.\nSearch results count: #{results.length}\n"
153
+ #
154
+ # print "Finished! Number of pages processed: #{client.number_of_pages}.\n"
155
+ #
156
+ # # get API usage
157
+ # usage_client = SelectPdf::UsageClient.new(api_key)
158
+ # usage = usage_client.get_usage(false)
159
+ # print("Usage: #{usage}\n")
160
+ # print('Conversions remained this month: ', usage['available'], "\n")
161
+ # rescue SelectPdf::ApiException => e
162
+ # print("An error occurred: #{e}")
163
+ # end
164
+ #
14
165
  module SelectPdf
166
+ # Multipart/form-data boundary
15
167
  MULTIPART_FORM_DATA_BOUNDARY = '------------SelectPdf_Api_Boundry_$'
168
+
169
+ # New line
16
170
  NEW_LINE = "\r\n"
17
- CLIENT_VERSION = '1.3.0'
171
+
172
+ # Library version
173
+ CLIENT_VERSION = '1.6.0'
174
+
175
+ # Default upgrade URL displayed in demo-mode error messages.
176
+ DEMO_UPGRADE_URL = 'https://selectpdf.com/pricing/'
18
177
 
19
178
  attr_reader :code, :message
20
179
  #
@@ -34,6 +193,115 @@ module SelectPdf
34
193
  end
35
194
  end
36
195
 
196
+ # Exception raised when the keyless demo endpoint refuses a request because of a rate limit (HTTP 429 or 503).
197
+ # Inspect reason to distinguish:
198
+ # * per_ip - the source IP exceeded its hourly conversion budget
199
+ # * concurrency - too many demo conversions are running right now
200
+ # * daily_cap - the global demo budget for today has been reached
201
+ class DemoRateLimitException < ApiException
202
+ # HTTP status code returned by the server (429 or 503).
203
+ attr_reader :status_code
204
+
205
+ # Machine-readable rate-limit reason: per_ip, concurrency or daily_cap.
206
+ attr_reader :reason
207
+
208
+ # Seconds the client should wait before retrying (parsed from the Retry-After header). Zero if absent.
209
+ attr_reader :retry_after
210
+
211
+ # URL the user can visit to upgrade out of demo mode.
212
+ attr_reader :upgrade_url
213
+
214
+ # The raw JSON body the server returned, for diagnostics / logging.
215
+ attr_reader :response_body
216
+
217
+ # Constructor invoked by the client when the demo endpoint returns a rate-limit error.
218
+ #
219
+ # @param status_code HTTP status code.
220
+ # @param reason Rate-limit reason.
221
+ # @param retry_after Seconds to wait before retrying.
222
+ # @param upgrade_url Upgrade URL.
223
+ # @param response_body Raw JSON body.
224
+ def initialize(status_code, reason, retry_after, upgrade_url, response_body)
225
+ upgrade = upgrade_url.nil? || upgrade_url.empty? ? DEMO_UPGRADE_URL : upgrade_url
226
+ retry_after = retry_after.to_i
227
+ retry_text = retry_after > 0 ? " Retry after #{retry_after}s." : ''
228
+ super("(#{status_code}) Demo rate limit reached (reason=#{reason || '?'}).#{retry_text} Upgrade at #{upgrade}.")
229
+
230
+ @status_code = status_code
231
+ @reason = reason || ''
232
+ @retry_after = retry_after
233
+ @upgrade_url = upgrade
234
+ @response_body = response_body
235
+ end
236
+ end
237
+
238
+ # Exception raised when the demo safety guard rejects a request because a URL field references a non-public host (HTTP 400).
239
+ # Inspect field for which parameter was rejected and reason for why.
240
+ class DemoSafetyException < ApiException
241
+ # HTTP status code returned by the server (400).
242
+ attr_reader :status_code
243
+
244
+ # Which input field was rejected: url, html, base_url, header_url, footer_url.
245
+ attr_reader :field
246
+
247
+ # Why the field was rejected: blocked_host, private_ip, loopback, link_local, cgnat, metadata, multicast, bad_scheme, bad_url, inline_internal_ref:<sub-reason>.
248
+ attr_reader :reason
249
+
250
+ # The raw JSON body the server returned, for diagnostics / logging.
251
+ attr_reader :response_body
252
+
253
+ # Constructor invoked by the client when the demo safety guard rejects a URL field.
254
+ #
255
+ # @param status_code HTTP status code.
256
+ # @param field Rejected field.
257
+ # @param reason Rejection reason.
258
+ # @param response_body Raw JSON body.
259
+ def initialize(status_code, field, reason, response_body)
260
+ super("(#{status_code}) Demo safety guard rejected #{field || '?'} (reason=#{reason || '?'}). Demo conversions cannot fetch internal/private hosts.")
261
+
262
+ @status_code = status_code
263
+ @field = field || ''
264
+ @reason = reason || ''
265
+ @response_body = response_body
266
+ end
267
+ end
268
+
269
+ # Exception raised when the caller tried to use a feature that demo mode does not support - most commonly PDF passwords.
270
+ # For paid keys, this exception is never raised.
271
+ class DemoUnsupportedException < ApiException
272
+ # HTTP status code returned by the server (400). Zero if the exception was raised by the local client guard before the request was sent.
273
+ attr_reader :status_code
274
+
275
+ # Which feature is unsupported: user_password, owner_password, async.
276
+ attr_reader :field
277
+
278
+ # URL the user can visit to upgrade out of demo mode.
279
+ attr_reader :upgrade_url
280
+
281
+ # The raw JSON body the server returned, for diagnostics / logging. Nil when raised by the local client guard.
282
+ attr_reader :response_body
283
+
284
+ # Constructor. Pass status_code 0 (and no body) for the local client-side guard that runs before the request is sent.
285
+ #
286
+ # @param status_code HTTP status code (0 for the local client guard).
287
+ # @param field Unsupported feature.
288
+ # @param upgrade_url Upgrade URL.
289
+ # @param response_body Raw JSON body.
290
+ def initialize(status_code, field, upgrade_url = nil, response_body = nil)
291
+ upgrade = upgrade_url.nil? || upgrade_url.empty? ? DEMO_UPGRADE_URL : upgrade_url
292
+ if status_code.to_i == 0
293
+ super("Feature '#{field || '?'}' is not available in demo mode. Construct HtmlToPdfClient with a paid API key, or upgrade at #{DEMO_UPGRADE_URL}.")
294
+ else
295
+ super("(#{status_code}) Feature '#{field || '?'}' is not available in demo mode. Upgrade at #{upgrade}.")
296
+ end
297
+
298
+ @status_code = status_code.to_i
299
+ @field = field || ''
300
+ @upgrade_url = upgrade
301
+ @response_body = response_body
302
+ end
303
+ end
304
+
37
305
  # Base class for API clients. Do not use this directly.
38
306
  class ApiClient
39
307
  # API endpoint
@@ -69,6 +337,22 @@ module SelectPdf
69
337
  # Number of pages of the pdf document resulted from the conversion.
70
338
  attr_reader :number_of_pages
71
339
 
340
+ # Subscription monthly conversion limit reported by the server (X-SelectPdf-Credits-Total response header).
341
+ # -1 = unlimited (Dedicated tier). Nil = the most recent response did not include credit info (e.g. demo endpoint, error response).
342
+ attr_reader :credits_total
343
+
344
+ # Conversions remaining in the current month reported by the server (X-SelectPdf-Credits-Remaining response header).
345
+ # -1 = unlimited (Dedicated tier). Nil = the most recent response did not include credit info.
346
+ attr_reader :credits_remaining
347
+
348
+ # Endpoint mode of the most recent response (X-SelectPdf-Mode response header): "production" or "demo".
349
+ # Empty when the response did not include the header (older server, or non-conversion endpoint).
350
+ attr_reader :mode
351
+
352
+ # Server-side execution path of the most recent response (X-SelectPdf-Execution response header): "in-process" or "worker".
353
+ # Empty for endpoints that do not perform a conversion (e.g. usage, web elements).
354
+ attr_reader :execution_mode
355
+
72
356
  # Class constructor
73
357
  def initialize
74
358
  # API endpoint
@@ -98,6 +382,15 @@ module SelectPdf
98
382
  # Job ID for asynchronous calls or for calls that require a second request.
99
383
  @job_id = ''
100
384
 
385
+ # Last HTTP status code.
386
+ @last_http_code = 0
387
+
388
+ # Response telemetry.
389
+ @credits_total = nil
390
+ @credits_remaining = nil
391
+ @mode = ''
392
+ @execution_mode = ''
393
+
101
394
  # Ping interval in seconds for asynchronous calls. Default value is 3 seconds.
102
395
  @async_calls_ping_interval = 3
103
396
 
@@ -110,32 +403,62 @@ module SelectPdf
110
403
  # @param out_stream Output response to this stream, if specified.
111
404
  # @return If output stream is not specified, return response.
112
405
  def perform_post(out_stream = nil)
406
+ uri = URI(@api_endpoint)
407
+ request = Net::HTTP::Post.new uri.request_uri
408
+ request.set_form_data(@parameters)
409
+ request['Content-Type'] = 'application/x-www-form-urlencoded'
410
+
411
+ execute_request(uri, request, out_stream)
412
+ end
413
+ protected :perform_post
414
+
415
+ # Create a multipart/form-data POST request (that can handle file uploads).
416
+ #
417
+ # @param out_stream Output response to this stream, if specified.
418
+ # @return If output stream is not specified, return response.
419
+ def perform_post_as_multipart_formdata(out_stream = nil)
420
+ uri = URI(@api_endpoint)
421
+ request = Net::HTTP::Post.new uri.request_uri
422
+ request.body = encode_multipart_form_data
423
+ request['Content-Type'] = "multipart/form-data; boundary=#{MULTIPART_FORM_DATA_BOUNDARY}"
424
+ request['Content-Length'] = request.body.bytesize.to_s
425
+
426
+ execute_request(uri, request, out_stream)
427
+ end
428
+ protected :perform_post_as_multipart_formdata
429
+
430
+ # Send a prepared request and process the response.
431
+ #
432
+ # @param uri Endpoint URI.
433
+ # @param request Prepared POST request.
434
+ # @param out_stream Output response to this stream, if specified.
435
+ # @return If output stream is not specified, return response.
436
+ def execute_request(uri, request, out_stream)
113
437
  # reset results
114
- @number_of_pages = 0
115
- @job_id = ''
438
+ reset_results
439
+
440
+ # add headers
441
+ request['selectpdf-api-client'] = "ruby-#{RUBY_VERSION}-#{CLIENT_VERSION}"
442
+ @headers.each do |key, value|
443
+ request[key] = value
444
+ end
116
445
 
117
- uri = URI(api_endpoint)
118
446
  http = Net::HTTP.new(uri.host, uri.port)
119
447
  http.use_ssl = @api_endpoint.downcase.start_with?('https')
120
448
  http.read_timeout = 600 # timeout in seconds 600s=10minutes
121
449
 
122
450
  http.start do |connection|
123
- request = Net::HTTP::Post.new uri.request_uri
124
- request.set_form_data(@parameters)
125
-
126
- # add headers
127
- request['selectpdf-api-client'] = "ruby-#{RUBY_VERSION}-#{CLIENT_VERSION}"
128
- request['Content-Type'] = 'application/x-www-form-urlencoded'
129
- @headers.each do |key, value|
130
- request[key] = value
131
- end
132
-
133
451
  connection.request request do |response|
134
- case response
135
- when Net::HTTPSuccess
452
+ @last_http_code = response.code.to_i
453
+
454
+ if response.is_a?(Net::HTTPAccepted)
455
+ # request accepted (for asynchronous jobs)
456
+ read_response_headers(response)
457
+
458
+ return nil
459
+ elsif response.is_a?(Net::HTTPSuccess)
136
460
  # all ok
137
- @number_of_pages = (response['selectpdf-api-pages'] || 0).to_i
138
- @job_id = response['selectpdf-api-jobid']
461
+ read_response_headers(response)
139
462
 
140
463
  return response.body unless out_stream # return response if out_stream is not provided
141
464
 
@@ -143,14 +466,20 @@ module SelectPdf
143
466
  response.read_body do |chunk|
144
467
  out_stream.write chunk
145
468
  end
146
- when Net::HTTPAccepted
147
- # request accepted (for asynchronous jobs)
148
- @job_id = response['selectpdf-api-jobid']
149
469
 
150
470
  return nil
151
471
  else
152
472
  # error - get error message
153
- raise ApiException.new(response.body, response.code), response.body
473
+ body = response.body.to_s.dup.force_encoding('UTF-8')
474
+
475
+ # The demo endpoint returns JSON error bodies for 400 / 413 / 429 / 503.
476
+ # Parse those into typed exceptions so callers can react programmatically.
477
+ if response['Content-Type'].to_s.downcase.include?('application/json')
478
+ demo_exception = build_demo_exception(response.code.to_i, body, response['Retry-After'])
479
+ raise demo_exception unless demo_exception.nil?
480
+ end
481
+
482
+ raise ApiException.new(body, response.code), body
154
483
  end
155
484
  end
156
485
  end
@@ -159,112 +488,167 @@ module SelectPdf
159
488
  rescue SocketError => e
160
489
  raise ApiException.new("Socket Error: #{e}"), "Socket Error: #{e}"
161
490
  rescue Timeout::Error
162
- raise ApiException.new("Connection Timeout: #{http.read_timeout}s exceeded"), "Connection Timeout: #{http.read_timeout}s exceeded"
491
+ raise ApiException.new('Connection Timeout: 600s exceeded'), 'Connection Timeout: 600s exceeded'
163
492
  rescue OpenSSL::SSL::SSLError => e
164
493
  raise ApiException.new("SSL Error: #{e}"), "SSL Error: #{e}"
165
494
  rescue StandardError => e
166
495
  raise ApiException.new("Connection refused: #{e}"), "Connection refused: #{e}"
167
496
  end
168
- protected :perform_post
497
+ private :execute_request
169
498
 
170
- # Create a multipart/form-data POST request (that can handle file uploads).
171
- #
172
- # @param out_stream Output response to this stream, if specified.
173
- # @return If output stream is not specified, return response.
174
- def perform_post_as_multipart_formdata(out_stream = nil)
175
- # reset results
499
+ # Reset the results of the previous call.
500
+ def reset_results
176
501
  @number_of_pages = 0
177
502
  @job_id = ''
503
+ @last_http_code = 0
504
+ @credits_total = nil
505
+ @credits_remaining = nil
506
+ @mode = ''
507
+ @execution_mode = ''
508
+ end
509
+ private :reset_results
178
510
 
179
- uri = URI(api_endpoint)
180
- http = Net::HTTP.new(uri.host, uri.port)
181
- http.use_ssl = @api_endpoint.downcase.start_with?('https')
182
- http.read_timeout = 600 # timeout in seconds 600s=10minutes
511
+ # Read the standard X-SelectPdf-* response headers.
512
+ #
513
+ # @param response HTTP response.
514
+ def read_response_headers(response)
515
+ pages = response['X-SelectPdf-Pages']
516
+ @number_of_pages = pages.to_i unless pages.nil? || pages.empty?
183
517
 
184
- http.start do |connection|
185
- request = Net::HTTP::Post.new uri.request_uri
186
- request.body = encode_multipart_form_data
518
+ job_id = response['X-SelectPdf-Job-Id']
519
+ @job_id = job_id unless job_id.nil? || job_id.empty?
187
520
 
188
- # add headers
189
- request['selectpdf-api-client'] = "ruby-#{RUBY_VERSION}-#{CLIENT_VERSION}"
190
- request['Content-Type'] = "multipart/form-data; boundary=#{MULTIPART_FORM_DATA_BOUNDARY}"
191
- request['Content-Length'] = request.body.length
521
+ @credits_total = parse_int_header(response['X-SelectPdf-Credits-Total'])
522
+ @credits_remaining = parse_int_header(response['X-SelectPdf-Credits-Remaining'])
192
523
 
193
- @headers.each do |key, value|
194
- request[key] = value
195
- end
524
+ mode = response['X-SelectPdf-Mode']
525
+ @mode = mode unless mode.nil? || mode.empty?
196
526
 
197
- connection.request request do |response|
198
- case response
199
- when Net::HTTPSuccess
200
- # all ok
201
- @number_of_pages = (response['selectpdf-api-pages'] || 0).to_i
202
- @job_id = response['selectpdf-api-jobid']
527
+ execution_mode = response['X-SelectPdf-Execution']
528
+ @execution_mode = execution_mode unless execution_mode.nil? || execution_mode.empty?
203
529
 
204
- return response.body unless out_stream # return response if out_stream is not provided
530
+ begin
531
+ on_response_headers_received(response)
532
+ rescue StandardError
533
+ # ignore errors in subclass header processing
534
+ nil
535
+ end
536
+ end
537
+ private :read_response_headers
205
538
 
206
- # out_steam is provided - write to it
207
- response.read_body do |chunk|
208
- out_stream.write chunk
209
- end
210
- when Net::HTTPAccepted
211
- # request accepted (for asynchronous jobs)
212
- @job_id = response['selectpdf-api-jobid']
539
+ # Hook called after a successful response, with the raw response.
540
+ # Subclasses can override it to capture endpoint-specific headers. Default implementation does nothing.
541
+ #
542
+ # @param response HTTP response.
543
+ def on_response_headers_received(response); end
544
+ protected :on_response_headers_received
213
545
 
214
- return nil
215
- else
216
- # error - get error message
217
- raise ApiException.new(response.body, response.code), response.body
218
- end
219
- end
546
+ # Parse an integer header value.
547
+ #
548
+ # @param value Header value.
549
+ # @return Integer value or nil if the value is missing or not an integer.
550
+ def parse_int_header(value)
551
+ return nil if value.nil?
552
+
553
+ value = value.strip
554
+ return nil unless value =~ /\A[-+]?\d+\z/
555
+
556
+ value.to_i
557
+ end
558
+ private :parse_int_header
559
+
560
+ # Build a typed demo exception from a JSON error body returned by the demo endpoint.
561
+ #
562
+ # The demo endpoint returns structured JSON for 400 / 413 / 429 / 503 errors:
563
+ # {"error":"rate_limited","reason":"per_ip","upgrade":"..."}
564
+ # {"error":"unsafe_url","field":"url","reason":"private_ip"}
565
+ # {"error":"unsupported_in_demo","field":"user_password","upgrade":"..."}
566
+ # {"error":"body_too_large","max_bytes":1048576,"upgrade":"..."}
567
+ #
568
+ # @param status_code HTTP status code.
569
+ # @param body Response body.
570
+ # @param retry_after_header Value of the Retry-After header.
571
+ # @return The exception to raise, or nil if the body is not a demo error.
572
+ def build_demo_exception(status_code, body, retry_after_header)
573
+ return nil if body.nil? || body.empty?
574
+
575
+ begin
576
+ json = JSON.parse(body)
577
+ rescue JSON::ParserError
578
+ return nil
579
+ end
580
+ return nil unless json.is_a?(Hash)
581
+
582
+ error = json_string(json, 'error')
583
+ return nil if error.nil? || error.empty?
584
+
585
+ reason = json_string(json, 'reason')
586
+ field = json_string(json, 'field')
587
+ upgrade = json_string(json, 'upgrade')
588
+
589
+ retry_after = 0
590
+ retry_after = retry_after_header.strip.to_i if retry_after_header.to_s.strip =~ /\A\d+\z/
591
+
592
+ case error
593
+ when 'rate_limited'
594
+ DemoRateLimitException.new(status_code, reason, retry_after, upgrade, body)
595
+ when 'unsafe_url'
596
+ DemoSafetyException.new(status_code, field, reason, body)
597
+ when 'unsupported_in_demo'
598
+ DemoUnsupportedException.new(status_code, field, upgrade, body)
599
+ when 'body_too_large'
600
+ message = "(#{status_code}) Demo request body exceeds the demo cap. Upgrade at #{upgrade || DEMO_UPGRADE_URL}."
601
+ ApiException.new(message)
220
602
  end
221
- rescue ApiException
222
- raise
223
- rescue SocketError => e
224
- raise ApiException.new("Socket Error: #{e}"), "Socket Error: #{e}"
225
- rescue Timeout::Error
226
- raise ApiException.new("Connection Timeout: #{http.read_timeout}s exceeded"), "Connection Timeout: #{http.read_timeout}s exceeded"
227
- rescue OpenSSL::SSL::SSLError => e
228
- raise ApiException.new("SSL Error: #{e}"), "SSL Error: #{e}"
229
- rescue StandardError => e
230
- raise ApiException.new("Connection refused: #{e}"), "Connection refused: #{e}"
231
603
  end
232
- protected :perform_post_as_multipart_formdata
604
+ private :build_demo_exception
605
+
606
+ # Get a top-level JSON field as string.
607
+ def json_string(json, field)
608
+ value = json[field]
609
+ value.nil? ? nil : value.to_s
610
+ end
611
+ private :json_string
233
612
 
234
613
  # Encode data for multipart POST
235
614
  def encode_multipart_form_data
236
- data = []
615
+ data = String.new # binary (ASCII-8BIT) buffer
237
616
 
238
617
  # encode regular parameters
239
618
  @parameters.each do |key, value|
240
- data << '--' + MULTIPART_FORM_DATA_BOUNDARY << 'Content-Disposition: form-data; name="%s"' % key << '' << value.to_s if value
619
+ next if value.nil?
620
+
621
+ data << "--#{MULTIPART_FORM_DATA_BOUNDARY}#{NEW_LINE}".b
622
+ data << "Content-Disposition: form-data; name=\"#{key}\"#{NEW_LINE}".b
623
+ data << NEW_LINE.b
624
+ data << value.to_s.b
625
+ data << NEW_LINE.b
241
626
  end
242
627
 
243
628
  # encode files
244
629
  @files.each do |key, value|
245
- File.open(value, 'rb') do |f|
246
- data << '--' + MULTIPART_FORM_DATA_BOUNDARY
247
- data << 'Content-Disposition: form-data; name="%s"; filename="%s"' % [key, value]
248
- data << 'Content-Type: application/octet-stream'
249
- data << ''
250
- data << f.read.force_encoding('UTF-8')
251
- end
630
+ data << "--#{MULTIPART_FORM_DATA_BOUNDARY}#{NEW_LINE}".b
631
+ data << "Content-Disposition: form-data; name=\"#{key}\"; filename=\"#{value}\"#{NEW_LINE}".b
632
+ data << "Content-Type: application/octet-stream#{NEW_LINE}".b
633
+ data << NEW_LINE.b
634
+ data << File.binread(value)
635
+ data << NEW_LINE.b
252
636
  end
253
637
 
254
638
  # encode additional binary data
255
639
  @binary_data.each do |key, value|
256
- data << '--' + MULTIPART_FORM_DATA_BOUNDARY
257
- data << 'Content-Disposition: form-data; name="%s"; filename="%s"' % [key, key]
258
- data << 'Content-Type: application/octet-stream'
259
- data << ''
260
- data << value.force_encoding('UTF-8')
640
+ data << "--#{MULTIPART_FORM_DATA_BOUNDARY}#{NEW_LINE}".b
641
+ data << "Content-Disposition: form-data; name=\"#{key}\"; filename=\"#{key}\"#{NEW_LINE}".b
642
+ data << "Content-Type: application/octet-stream#{NEW_LINE}".b
643
+ data << NEW_LINE.b
644
+ data << value.to_s.b
645
+ data << NEW_LINE.b
261
646
  end
262
647
 
263
648
  # final boundary
264
- data << '--' + MULTIPART_FORM_DATA_BOUNDARY + '--'
265
- data << ''
649
+ data << "--#{MULTIPART_FORM_DATA_BOUNDARY}--#{NEW_LINE}".b
266
650
 
267
- data.join(NEW_LINE)
651
+ data
268
652
  end
269
653
  private :encode_multipart_form_data
270
654
 
@@ -287,6 +671,18 @@ module SelectPdf
287
671
  @job_id
288
672
  end
289
673
  protected :start_async_job_multipart_form_data
674
+
675
+ # Copy the results of a finished asynchronous job (number of pages and response telemetry) into this client.
676
+ #
677
+ # @param async_job_client The async job client that retrieved the result.
678
+ def copy_async_job_results(async_job_client)
679
+ @number_of_pages = async_job_client.number_of_pages
680
+ @credits_total = async_job_client.credits_total unless async_job_client.credits_total.nil?
681
+ @credits_remaining = async_job_client.credits_remaining unless async_job_client.credits_remaining.nil?
682
+ @mode = async_job_client.mode unless async_job_client.mode.empty?
683
+ @execution_mode = async_job_client.execution_mode unless async_job_client.execution_mode.empty?
684
+ end
685
+ protected :copy_async_job_results
290
686
  end
291
687
 
292
688
  # Get usage details for SelectPdf Online API.
@@ -377,6 +773,9 @@ module SelectPdf
377
773
 
378
774
  # Blink rendering engine.
379
775
  BLINK = 'Blink'
776
+
777
+ # Chromium rendering engine.
778
+ CHROMIUM = 'Chromium'
380
779
  end
381
780
 
382
781
  # Protocol used for secure (HTTPS) connections.
@@ -466,20 +865,241 @@ module SelectPdf
466
865
  HTML = 1
467
866
  end
468
867
 
868
+ # PDF conformance target for the generated document.
869
+ # Tagged standards (PdfA3A) require the Blink or Chromium rendering engine.
870
+ # When no engine is specified the API promotes the request to Chromium and reports the engine used in the X-SelectPdf-Engine response header.
871
+ class PdfStandard
872
+ # The complete PDF feature set. Default.
873
+ FULL = 'Full'
874
+
875
+ # PDF/A - long term archiving.
876
+ PDF_A = 'PdfA'
877
+
878
+ # PDF/A-2B - long term archiving, transparencies allowed.
879
+ PDF_A2B = 'PdfA2B'
880
+
881
+ # PDF/A-3A - the accessible level of PDF/A-3. Implies a tagged document and can carry a ZUGFeRD / Factur-X electronic invoice.
882
+ PDF_A3A = 'PdfA3A'
883
+
884
+ # PDF/A-3B - long term archiving with arbitrary embedded files. Can carry a ZUGFeRD / Factur-X electronic invoice.
885
+ PDF_A3B = 'PdfA3B'
886
+
887
+ # PDF/A-3U - PDF/A-3B with Unicode mapping for all text. Can carry a ZUGFeRD / Factur-X electronic invoice.
888
+ PDF_A3U = 'PdfA3U'
889
+
890
+ # PDF/X - graphics exchange.
891
+ PDF_X = 'PdfX'
892
+
893
+ # PDF/SiqQ Level A - suitable for digital signatures, external links disabled.
894
+ PDF_SIQQ_A = 'PdfSiqQ_A'
895
+
896
+ # PDF/SiqQ Level B - suitable for digital signatures.
897
+ PDF_SIQQ_B = 'PdfSiqQ_B'
898
+ end
899
+
900
+ # The data profile of a ZUGFeRD / Factur-X hybrid electronic invoice.
901
+ # The profile determines how much of the EN 16931 semantic model the embedded XML carries.
902
+ class ZugferdProfile
903
+ # MINIMUM - accounting information only. Not a complete invoice.
904
+ MINIMUM = 'Minimum'
905
+
906
+ # BASIC WL - header and footer data without invoice lines. Not a complete invoice.
907
+ BASIC_WL = 'Basic_WL'
908
+
909
+ # BASIC - a subset of EN 16931 covering simple invoices, with lines.
910
+ BASIC = 'Basic'
911
+
912
+ # EN 16931 (formerly COMFORT) - the full European semantic standard.
913
+ EN16931 = 'En16931'
914
+
915
+ # EXTENDED - EN 16931 plus additional business terms.
916
+ EXTENDED = 'Extended'
917
+
918
+ # XRECHNUNG - the German public-sector reference profile. The embedded file is named xrechnung.xml instead of factur-x.xml.
919
+ XRECHNUNG = 'XRechnung'
920
+ end
921
+
922
+ # How the embedded invoice XML relates to the visible invoice page.
923
+ # When not set, the API derives this from the profile: Alternative for Minimum and Basic_WL, Data for the rest.
924
+ # Minimum and Basic_WL combined with Data are rejected, because those profiles do not carry a complete invoice.
925
+ class ZugferdRelationship
926
+ # The XML and the visible page carry exactly the same invoice content.
927
+ # Mandatory in Germany for the Basic, En16931, Extended and XRechnung profiles.
928
+ DATA = 'Data'
929
+
930
+ # The visible page carries more than the XML does - always the case for the Minimum and Basic_WL profiles - or the page was generated from the XML.
931
+ ALTERNATIVE = 'Alternative'
932
+
933
+ # The XML is the source the visible page was produced from.
934
+ SOURCE = 'Source'
935
+
936
+ # The XML supplements the visible page.
937
+ SUPPLEMENT = 'Supplement'
938
+ end
939
+
940
+ # The metadata schema used to identify a hybrid invoice inside the PDF.
941
+ class ZugferdSchema
942
+ # Factur-X 1.0 / ZUGFeRD 2.x - the current schema. Default.
943
+ FACTUR_X_10 = 'FacturX10'
944
+
945
+ # ZUGFeRD 2.0 - the legacy schema, deprecated but still accepted. Use only for recipients that explicitly require it.
946
+ ZUGFERD_20 = 'Zugferd20'
947
+ end
948
+
469
949
  # Html To Pdf Conversion with SelectPdf Online API.
470
950
  #
471
951
  # Code sample:
472
952
  #
473
- # {include:file:samples/html_to_pdf_main.rb}
953
+ # require 'selectpdf'
954
+ #
955
+ # $stdout.sync = true
956
+ #
957
+ # print "This is SelectPdf-#{SelectPdf::CLIENT_VERSION}\n"
958
+ #
959
+ # url = 'https://selectpdf.com'
960
+ # local_file = 'Test.pdf'
961
+ # api_key = 'Your API key here'
962
+ #
963
+ # begin
964
+ # client = SelectPdf::HtmlToPdfClient.new(api_key)
965
+ #
966
+ # # set parameters - see full list at https://selectpdf.com/html-to-pdf-api/
967
+ #
968
+ # client.page_size = SelectPdf::PageSize::A4 # PDF page size
969
+ # client.page_orientation = SelectPdf::PageOrientation::PORTRAIT # PDF page orientation
970
+ # client.margins = 0 # PDF page margins
971
+ # client.rendering_engine = SelectPdf::RenderingEngine::WEBKIT # rendering engine
972
+ # client.conversion_delay = 1 # conversion delay
973
+ # client.navigation_timeout = 30 # navigation timeout
974
+ # client.page_numbers = false # page numbers
975
+ # client.page_breaks_enhanced_algorithm = true # enhanced page break algorithm
976
+ #
977
+ # # additional properties
978
+ #
979
+ # # client.use_css_print = true # enable CSS media print
980
+ # # client.disable_javascript = true # disable javascript
981
+ # # client.disable_internal_links = true # disable internal links
982
+ # # client.disable_external_links = true # disable external links
983
+ # # client.keep_images_together = true # keep images together
984
+ # # client.scale_images = true # scale images to create smaller pdfs
985
+ # # client.single_page_pdf = true # generate a single page PDF
986
+ # # client.user_password = 'password' # secure the PDF with a password
987
+ #
988
+ # # generate automatic bookmarks
989
+ #
990
+ # # client.pdf_bookmarks_selectors = 'H1, H2' # create outlines (bookmarks) for the specified elements
991
+ # # client.viewer_page_mode = SelectPdf::PageMode::USE_OUTLINES # display outlines (bookmarks) in viewer
992
+ #
993
+ # print "Starting conversion ...\n"
994
+ #
995
+ # # convert url to file
996
+ # client.convert_url_to_file(url, local_file)
997
+ #
998
+ # # convert url to memory
999
+ # # pdf = client.convert_url(url)
1000
+ #
1001
+ # # convert html string to file
1002
+ # # client.convert_html_string_to_file('This is some <b>html</b>.', local_file)
1003
+ #
1004
+ # # convert html string to memory
1005
+ # # pdf = client.convert_html_string('This is some <b>html</b>.')
1006
+ #
1007
+ # print "Finished! Number of pages: #{client.number_of_pages}.\n"
1008
+ #
1009
+ # # get API usage
1010
+ # usage_client = SelectPdf::UsageClient.new(api_key)
1011
+ # usage = usage_client.get_usage(false)
1012
+ # print("Usage: #{usage}\n")
1013
+ # print('Conversions remained this month: ', usage['available'], "\n")
1014
+ # rescue SelectPdf::ApiException => e
1015
+ # print("An error occurred: #{e}")
1016
+ # end
474
1017
  class HtmlToPdfClient < ApiClient
1018
+ # Names of the parameters the demo endpoint clamped on the most recent conversion (e.g. ["max_load_time", "engine"]),
1019
+ # parsed from the X-SelectPdf-Demo-Clamped response header.
1020
+ # Empty array if nothing was clamped or for non-demo responses. "Clamped" means the value was modified (capped, force-set), not discarded.
1021
+ attr_reader :clamped_fields
1022
+
1023
+ # Names of the parameters the demo endpoint silently dropped on the most recent conversion (e.g. ["auth_username", "cookies"]),
1024
+ # parsed from the X-SelectPdf-Demo-Dropped response header.
1025
+ # The demo endpoint refuses to honor a small set of fields for safety reasons - auth credentials, cookies, raw_parameters, pdf_name, async.
1026
+ # Empty array if nothing was dropped or for non-demo responses. Clamped = value modified, dropped = value thrown away.
1027
+ attr_reader :dropped_fields
1028
+
475
1029
  # Construct the Html To Pdf Client.
476
1030
  #
477
- # @param api_key API Key.
478
- def initialize(api_key)
1031
+ # Pass a paid API key for production use. Pass nil, empty string, or "demo" (case-insensitive) to use the keyless demo endpoint -
1032
+ # output is watermarked and capped at 5 pages, but no signup is required.
1033
+ #
1034
+ # @param api_key API Key. Defaults to nil, which selects demo mode. Pass a real key for full unwatermarked output.
1035
+ def initialize(api_key = nil)
479
1036
  super()
480
- @api_endpoint = 'https://selectpdf.com/api2/convert/'
481
- @parameters['key'] = api_key
1037
+
1038
+ @clamped_fields = []
1039
+ @dropped_fields = []
1040
+
1041
+ @demo_mode = api_key.nil? || api_key.empty? || api_key.strip.casecmp('demo').zero?
1042
+
1043
+ if @demo_mode
1044
+ # Demo is keyless - the key parameter is not sent.
1045
+ @api_endpoint = 'https://selectpdf.com/api2/convert/demo/'
1046
+ else
1047
+ @api_endpoint = 'https://selectpdf.com/api2/convert/'
1048
+ @parameters['key'] = api_key
1049
+ end
1050
+ end
1051
+
1052
+ # True if the client was constructed for the keyless demo endpoint (api_key was nil, empty, or "demo").
1053
+ # Set at construction time and stable for the lifetime of the client. Changing api_endpoint does NOT change this flag.
1054
+ #
1055
+ # @return True if the client is in demo mode.
1056
+ def demo_mode?
1057
+ @demo_mode
1058
+ end
1059
+
1060
+ # True if the most recent response was tagged X-SelectPdf-Mode: demo (i.e. the request actually landed on a demo endpoint).
1061
+ #
1062
+ # @return True if the last response came from the demo endpoint.
1063
+ def demo_response?
1064
+ !@mode.nil? && @mode.casecmp('demo').zero?
1065
+ end
1066
+
1067
+ # True if the most recent response had any clamped fields.
1068
+ #
1069
+ # @return True if any field was clamped.
1070
+ def was_clamped?
1071
+ !@clamped_fields.nil? && !@clamped_fields.empty?
1072
+ end
1073
+
1074
+ # True if the most recent response reported any dropped fields.
1075
+ #
1076
+ # @return True if any field was dropped.
1077
+ def was_any_field_dropped?
1078
+ !@dropped_fields.nil? && !@dropped_fields.empty?
1079
+ end
1080
+
1081
+ # Capture demo-specific response headers (the demo-clamped fields list and the demo-dropped fields list).
1082
+ #
1083
+ # @param response HTTP response.
1084
+ def on_response_headers_received(response)
1085
+ @clamped_fields = split_fields(response['X-SelectPdf-Demo-Clamped'])
1086
+ @dropped_fields = split_fields(response['X-SelectPdf-Demo-Dropped'])
482
1087
  end
1088
+ protected :on_response_headers_received
1089
+
1090
+ # Split a comma separated header value.
1091
+ def split_fields(raw)
1092
+ return [] if raw.nil? || raw.empty?
1093
+
1094
+ raw.split(',', -1).map(&:strip)
1095
+ end
1096
+ private :split_fields
1097
+
1098
+ # Raise DemoUnsupportedException for asynchronous calls in demo mode (the demo endpoint is synchronous only).
1099
+ def ensure_async_supported
1100
+ raise DemoUnsupportedException.new(0, 'async') if @demo_mode
1101
+ end
1102
+ private :ensure_async_supported
483
1103
 
484
1104
  # Convert the specified url to PDF.
485
1105
  # SelectPdf online API can convert http:// and https:// publicly available urls.
@@ -559,7 +1179,10 @@ module SelectPdf
559
1179
  #
560
1180
  # @param url Address of the web page being converted.
561
1181
  # @return The resulted pdf.
1182
+ # Raises DemoUnsupportedException in demo mode (the demo endpoint does not run asynchronous jobs).
562
1183
  def convert_url_async(url)
1184
+ ensure_async_supported
1185
+
563
1186
  if !url.downcase.start_with?('http://') && !url.downcase.start_with?('https://')
564
1187
  raise ApiException.new('The supported protocols for the converted webpage are http:// and https://.'), 'The supported protocols for the converted webpage are http:// and https://.'
565
1188
  end
@@ -593,7 +1216,7 @@ module SelectPdf
593
1216
 
594
1217
  next if result.nil?
595
1218
 
596
- @number_of_pages = async_job_client.number_of_pages
1219
+ copy_async_job_results(async_job_client)
597
1220
  return result
598
1221
  end
599
1222
 
@@ -677,7 +1300,10 @@ module SelectPdf
677
1300
  #
678
1301
  # @param html_string HTML string with the content being converted.
679
1302
  # @param base_url Base url used to resolve relative paths to resources (css, images, javascript, etc). Must be a http:// or https:// publicly available url.
1303
+ # Raises DemoUnsupportedException in demo mode (the demo endpoint does not run asynchronous jobs).
680
1304
  def convert_html_string_with_base_url_async(html_string, base_url)
1305
+ ensure_async_supported
1306
+
681
1307
  @parameters.delete('url')
682
1308
  @parameters['html'] = html_string
683
1309
  @parameters['base_url'] = base_url unless base_url.nil? || base_url.empty?
@@ -703,7 +1329,7 @@ module SelectPdf
703
1329
 
704
1330
  next if result.nil?
705
1331
 
706
- @number_of_pages = async_job_client.number_of_pages
1332
+ copy_async_job_results(async_job_client)
707
1333
  return result
708
1334
  end
709
1335
 
@@ -867,26 +1493,68 @@ module SelectPdf
867
1493
 
868
1494
  # Set the rendering engine used for the HTML to PDF conversion. Default value is WebKit.
869
1495
  #
870
- # @param rendering_engine HTML rendering engine. Use constants from SelectPdf::RenderingEngine class.
1496
+ # @param rendering_engine HTML rendering engine. Possible values: WebKit, Restricted, Blink, Chromium. Use constants from SelectPdf::RenderingEngine class.
871
1497
  def rendering_engine=(rendering_engine)
872
- unless /(?i)^(WebKit|Restricted|Blink)$/.match(rendering_engine)
873
- raise ApiException.new('Allowed values for Rendering Engine: WebKit, Restricted, Blink.'), 'Allowed values for Rendering Engine: WebKit, Restricted, Blink.'
1498
+ unless /(?i)^(WebKit|Restricted|Blink|Chromium)$/.match(rendering_engine)
1499
+ raise ApiException.new('Allowed values for Rendering Engine: WebKit, Restricted, Blink, Chromium.'), 'Allowed values for Rendering Engine: WebKit, Restricted, Blink, Chromium.'
874
1500
  end
875
1501
 
876
1502
  @parameters['engine'] = rendering_engine
877
1503
  end
878
1504
 
1505
+ # Produce a tagged, accessible PDF: a logical structure tree covering headings, paragraphs, lists, tables,
1506
+ # figures with alternate text, links and reading order. The default value is False.
1507
+ #
1508
+ # Requires the Blink or Chromium rendering engine - the WebKit engines cannot produce a structure tree.
1509
+ # If no engine is set, the API promotes the request to Chromium and reports it in the X-SelectPdf-Engine response header.
1510
+ # Setting an explicit WebKit engine together with tagged output is rejected by the API.
1511
+ # A tagged document also needs a title, so set doc_title - the converter falls back to the HTML document title when it is not set.
1512
+ #
1513
+ # @param tagged Produce a tagged, accessible PDF or not.
1514
+ def tagged=(tagged)
1515
+ @parameters['tagged'] = tagged
1516
+ end
1517
+
1518
+ # Set the PDF conformance target - PDF/A for archiving, PDF/X for graphics exchange, PDF/SiqQ for digital signatures. The default value is Full.
1519
+ #
1520
+ # PdfA3A is the accessible level of PDF/A-3: it implies a tagged document, so it carries the same rendering-engine requirement as tagged.
1521
+ #
1522
+ # @param pdf_standard PDF conformance target. Possible values: Full, PdfA, PdfA2B, PdfA3A, PdfA3B, PdfA3U, PdfX, PdfSiqQ_A, PdfSiqQ_B.
1523
+ # Use constants from SelectPdf::PdfStandard class.
1524
+ def pdf_standard=(pdf_standard)
1525
+ unless /(?i)^(Full|PdfA|PdfA2B|PdfA3A|PdfA3B|PdfA3U|PdfX|PdfSiqQ_A|PdfSiqQ_B)$/.match(pdf_standard)
1526
+ raise ApiException.new('Allowed values for Pdf Standard: Full, PdfA, PdfA2B, PdfA3A, PdfA3B, PdfA3U, PdfX, PdfSiqQ_A, PdfSiqQ_B.'),
1527
+ 'Allowed values for Pdf Standard: Full, PdfA, PdfA2B, PdfA3A, PdfA3B, PdfA3U, PdfX, PdfSiqQ_A, PdfSiqQ_B.'
1528
+ end
1529
+
1530
+ @parameters['pdf_standard'] = pdf_standard
1531
+ end
1532
+
1533
+ # Set the natural language of the document, for example "en-US" or "de-DE".
1534
+ # Written as the PDF /Lang entry and onto tagged structure elements. The default value is "en-US".
1535
+ #
1536
+ # @param doc_language Language tag, for example "en-US".
1537
+ def doc_language=(doc_language)
1538
+ @parameters['doc_language'] = doc_language
1539
+ end
1540
+
879
1541
  # Set PDF user password.
1542
+ # Not available in demo mode: raises DemoUnsupportedException if the client was constructed without an API key.
880
1543
  #
881
1544
  # @param user_password PDF user password.
882
1545
  def user_password=(user_password)
1546
+ raise DemoUnsupportedException.new(0, 'user_password') if @demo_mode && !(user_password.nil? || user_password.empty?)
1547
+
883
1548
  @parameters['user_password'] = user_password
884
1549
  end
885
1550
 
886
1551
  # Set PDF owner password.
1552
+ # Not available in demo mode: raises DemoUnsupportedException if the client was constructed without an API key.
887
1553
  #
888
1554
  # @param owner_password PDF owner password.
889
1555
  def owner_password=(owner_password)
1556
+ raise DemoUnsupportedException.new(0, 'owner_password') if @demo_mode && !(owner_password.nil? || owner_password.empty?)
1557
+
890
1558
  @parameters['owner_password'] = owner_password
891
1559
  end
892
1560
 
@@ -905,6 +1573,17 @@ module SelectPdf
905
1573
  @parameters['web_page_height'] = web_page_height
906
1574
  end
907
1575
 
1576
+ # Leave out the content below the web page height (set with web_page_height) instead of letting the page flow onto further pages.
1577
+ #
1578
+ # When not set, each rendering engine keeps its own behavior: WebKit and WebKit Restricted leave the content out whenever a web page height is set,
1579
+ # Blink and Chromium convert the whole page. Set it to True or False to choose explicitly. It needs a non-zero web page height; with 0 there is no
1580
+ # height to fix the page at and the setting is ignored. With WebKit, a fixed size also cuts off content wider than the web page width.
1581
+ #
1582
+ # @param web_page_fixed_size True to cut the page at the web page height, False to convert the whole page.
1583
+ def web_page_fixed_size=(web_page_fixed_size)
1584
+ @parameters['web_page_fixed_size'] = web_page_fixed_size
1585
+ end
1586
+
908
1587
  # Introduce a delay (in seconds) before the actual conversion to allow the web page to fully load. This property is an alias for conversion_delay.
909
1588
  # The default value is 1 second. Use a larger value if the web page has content that takes time to render when it is displayed in the browser.
910
1589
  #
@@ -1448,10 +2127,39 @@ module SelectPdf
1448
2127
  end
1449
2128
 
1450
2129
  # Set HTTP cookies for the web page being converted.
2130
+ # The demo endpoint does not send cookies; it reports the value in dropped_fields.
1451
2131
  #
1452
- # @param cookies HTTP cookies that will be sent to the page being converted.
2132
+ # @param cookies HTTP cookies that will be sent to the page being converted (a Hash of cookie names and values).
1453
2133
  def cookies=(cookies)
1454
- @parameters['cookies_string'] = URI.encode_www_form(cookies)
2134
+ # Serialized as name=value&name=value&..., each part percent-encoded (spaces as %20, not +), the same way the .NET client sends it.
2135
+ serialized = ''.dup
2136
+ cookies.each do |name, value|
2137
+ serialized << "#{encode_cookie_part(name)}=#{encode_cookie_part(value)}&"
2138
+ end
2139
+
2140
+ @parameters['cookies_string'] = serialized
2141
+ end
2142
+
2143
+ # Percent-encode a cookie name or value.
2144
+ def encode_cookie_part(part)
2145
+ URI.encode_www_form_component(part.to_s).gsub('+', '%20')
2146
+ end
2147
+ private :encode_cookie_part
2148
+
2149
+ # Set the user name for HTTP Basic authentication on the web page being converted.
2150
+ # Use it together with auth_password. The demo endpoint does not send credentials; it reports the value in dropped_fields.
2151
+ #
2152
+ # @param auth_username User name for HTTP Basic authentication.
2153
+ def auth_username=(auth_username)
2154
+ @parameters['auth_username'] = auth_username
2155
+ end
2156
+
2157
+ # Set the password for HTTP Basic authentication on the web page being converted.
2158
+ # Use it together with auth_username. The demo endpoint does not send credentials; it reports the value in dropped_fields.
2159
+ #
2160
+ # @param auth_password Password for HTTP Basic authentication.
2161
+ def auth_password=(auth_password)
2162
+ @parameters['auth_password'] = auth_password
1455
2163
  end
1456
2164
 
1457
2165
  # Set a custom parameter. Do not use this method unless advised by SelectPdf.
@@ -1473,21 +2181,395 @@ module SelectPdf
1473
2181
  end
1474
2182
  end
1475
2183
 
1476
- # Get the locations of certain web elements.
1477
- # This is retrieved if pdf_web_elements_selectors parameter was set during the initial conversion call and elements were found to match the selectors.
1478
- class WebElementsClient < ApiClient
1479
- # Construct the web elements client.
2184
+ # Create ZUGFeRD / Factur-X hybrid electronic invoices with SelectPdf Online API.
2185
+ #
2186
+ # A hybrid electronic invoice is one PDF/A-3 file carrying both halves of the invoice: the page a human reads,
2187
+ # and the XML a recipient's accounting system reads. This client converts a URL or an HTML string into the visible invoice
2188
+ # and embeds the XML into it as an associated file, with the metadata invoice software looks for.
2189
+ #
2190
+ # It derives from HtmlToPdfClient, so every conversion setting - page size, margins, headers, footers, rendering engine - applies here too.
2191
+ # Use the create_from_* methods rather than the inherited convert_* methods: the invoice endpoint takes a multipart request,
2192
+ # because the XML is uploaded as a file part.
2193
+ #
2194
+ # The carrier document must be PDF/A-3. The default is SelectPdf::PdfStandard::PDF_A3A, the accessible level, which the standards recommend
2195
+ # because it makes the visible invoice readable by assistive technology as well as archivable. Because PdfA3A is a tagged standard,
2196
+ # a request that does not set a rendering engine is promoted to Chromium by the API, which reports the engine used in the X-SelectPdf-Engine response header.
2197
+ #
2198
+ # There is no way to attach an invoice XML to an existing PDF you already have: the XML can only be embedded into a document created as PDF/A-3.
2199
+ #
2200
+ # Code sample:
2201
+ #
2202
+ # require 'selectpdf'
2203
+ #
2204
+ # api_key = 'Your API key here'
2205
+ # invoice_html = '<html><body><h1>Invoice INV-2026-001</h1></body></html>'
2206
+ # invoice_xml = 'factur-x.xml'
2207
+ # local_file = 'Invoice.pdf'
2208
+ #
2209
+ # begin
2210
+ # client = SelectPdf::InvoiceClient.new(api_key)
2211
+ #
2212
+ # client.invoice_xml_file = invoice_xml
2213
+ # client.zugferd_profile = SelectPdf::ZugferdProfile::EN16931
2214
+ # client.doc_title = 'Invoice INV-2026-001'
2215
+ #
2216
+ # client.create_from_html_string_to_file(invoice_html, local_file)
2217
+ #
2218
+ # print "Finished! Number of pages: #{client.number_of_pages}.\n"
2219
+ # rescue SelectPdf::ApiException => e
2220
+ # print("An error occurred: #{e}")
2221
+ # end
2222
+ class InvoiceClient < HtmlToPdfClient
2223
+ # The production endpoint for hybrid electronic invoices.
2224
+ INVOICE_ENDPOINT = 'https://selectpdf.com/api2/invoice/'
2225
+
2226
+ # Construct the Invoice Client.
2227
+ #
2228
+ # Unlike HtmlToPdfClient, this client has no demo mode - the keyless demo endpoint does not produce electronic invoices - so an API key is required.
2229
+ # Raises ApiException when no API key is supplied.
1480
2230
  #
1481
2231
  # @param api_key API Key.
1482
- # @param job_id Job ID.
1483
- def initialize(api_key, job_id)
1484
- super()
1485
- @api_endpoint = 'https://selectpdf.com/api2/webelements/'
1486
- @parameters['key'] = api_key
1487
- @parameters['job_id'] = job_id
1488
- end
2232
+ def initialize(api_key)
2233
+ if api_key.nil? || api_key.empty? || api_key.strip.downcase == 'demo'
2234
+ raise ApiException.new('An API key is required to create electronic invoices. The keyless demo endpoint does not support them.'),
2235
+ 'An API key is required to create electronic invoices. The keyless demo endpoint does not support them.'
2236
+ end
1489
2237
 
1490
- # Get the locations of certain web elements.
2238
+ super(api_key)
2239
+
2240
+ @api_endpoint = INVOICE_ENDPOINT
2241
+
2242
+ # The carrier has to be PDF/A-3; default to the accessible level, which the standards recommend. Overridable with pdf_standard.
2243
+ @parameters['pdf_standard'] = PdfStandard::PDF_A3A
2244
+ end
2245
+
2246
+ # Set the invoice XML from a local file.
2247
+ #
2248
+ # Only the content of the file is used - the name recorded inside the PDF is the one the standard prescribes
2249
+ # ("factur-x.xml", or "xrechnung.xml" for the XRECHNUNG profile), because recipients look it up by name.
2250
+ #
2251
+ # @param invoice_xml_file Path to the local invoice XML file.
2252
+ def invoice_xml_file=(invoice_xml_file)
2253
+ @binary_data.delete('zugferd_xml')
2254
+ @files['zugferd_xml'] = invoice_xml_file
2255
+ end
2256
+
2257
+ # Set the invoice XML from memory.
2258
+ #
2259
+ # @param invoice_xml The invoice XML, as a string or as raw bytes (a binary string). Text strings are sent UTF-8 encoded.
2260
+ def invoice_xml=(invoice_xml)
2261
+ data = invoice_xml.to_s
2262
+ data = data.encode('UTF-8') unless data.encoding == Encoding::BINARY || data.encoding == Encoding::UTF_8
2263
+
2264
+ @files.delete('zugferd_xml')
2265
+ @binary_data['zugferd_xml'] = data.b
2266
+ end
2267
+
2268
+ # Set the data profile of the invoice XML. Required.
2269
+ #
2270
+ # @param zugferd_profile The invoice data profile. Possible values: Minimum, Basic_WL, Basic, En16931, Extended, XRechnung.
2271
+ # Use constants from SelectPdf::ZugferdProfile class.
2272
+ def zugferd_profile=(zugferd_profile)
2273
+ unless /(?i)^(Minimum|Basic_WL|Basic|En16931|Extended|XRechnung)$/.match(zugferd_profile)
2274
+ raise ApiException.new('Allowed values for Zugferd Profile: Minimum, Basic_WL, Basic, En16931, Extended, XRechnung.'),
2275
+ 'Allowed values for Zugferd Profile: Minimum, Basic_WL, Basic, En16931, Extended, XRechnung.'
2276
+ end
2277
+
2278
+ @parameters['zugferd_profile'] = zugferd_profile
2279
+ end
2280
+
2281
+ # Set how the embedded XML relates to the visible invoice page.
2282
+ #
2283
+ # Optional. When not set, the API derives it from the profile: Alternative for Minimum and Basic_WL, which do not carry a complete invoice,
2284
+ # and Data for the rest. Those two profiles combined with Data are rejected by the API.
2285
+ #
2286
+ # @param zugferd_relationship The relationship between XML and page. Possible values: Data, Alternative, Source, Supplement.
2287
+ # Use constants from SelectPdf::ZugferdRelationship class.
2288
+ def zugferd_relationship=(zugferd_relationship)
2289
+ unless /(?i)^(Data|Alternative|Source|Supplement)$/.match(zugferd_relationship)
2290
+ raise ApiException.new('Allowed values for Zugferd Relationship: Data, Alternative, Source, Supplement.'),
2291
+ 'Allowed values for Zugferd Relationship: Data, Alternative, Source, Supplement.'
2292
+ end
2293
+
2294
+ @parameters['zugferd_relationship'] = zugferd_relationship
2295
+ end
2296
+
2297
+ # Set the metadata schema identifying the invoice. The default value is FacturX10.
2298
+ #
2299
+ # @param zugferd_schema The invoice metadata schema. Possible values: FacturX10, Zugferd20. Use constants from SelectPdf::ZugferdSchema class.
2300
+ def zugferd_schema=(zugferd_schema)
2301
+ unless /(?i)^(FacturX10|Zugferd20)$/.match(zugferd_schema)
2302
+ raise ApiException.new('Allowed values for Zugferd Schema: FacturX10, Zugferd20.'), 'Allowed values for Zugferd Schema: FacturX10, Zugferd20.'
2303
+ end
2304
+
2305
+ @parameters['zugferd_schema'] = zugferd_schema
2306
+ end
2307
+
2308
+ # Create a hybrid electronic invoice from the invoice page at the specified url.
2309
+ #
2310
+ # @param url Url of the invoice page.
2311
+ # @return The resulted hybrid invoice PDF.
2312
+ def create_from_url(url)
2313
+ prepare_invoice_url(url)
2314
+ @parameters['async'] = 'False'
2315
+
2316
+ perform_post_as_multipart_formdata
2317
+ end
2318
+
2319
+ # Create a hybrid electronic invoice from the invoice page at the specified url and write it to an output stream.
2320
+ #
2321
+ # @param url Url of the invoice page.
2322
+ # @param stream The output stream where the resulted PDF will be written.
2323
+ def create_from_url_to_stream(url, stream)
2324
+ prepare_invoice_url(url)
2325
+ @parameters['async'] = 'False'
2326
+
2327
+ perform_post_as_multipart_formdata(stream)
2328
+ end
2329
+
2330
+ # Create a hybrid electronic invoice from the invoice page at the specified url and write it to a local file.
2331
+ #
2332
+ # @param url Url of the invoice page.
2333
+ # @param file_path Local file including path if necessary.
2334
+ def create_from_url_to_file(url, file_path)
2335
+ write_invoice_to_file(file_path) do |file|
2336
+ create_from_url_to_stream(url, file)
2337
+ end
2338
+ end
2339
+
2340
+ # Create a hybrid electronic invoice from a raw HTML string.
2341
+ #
2342
+ # @param html_string The invoice HTML.
2343
+ # @return The resulted hybrid invoice PDF.
2344
+ def create_from_html_string(html_string)
2345
+ create_from_html_string_with_base_url(html_string, nil)
2346
+ end
2347
+
2348
+ # Create a hybrid electronic invoice from a raw HTML string. Use a base url to resolve relative paths to resources.
2349
+ #
2350
+ # @param html_string The invoice HTML.
2351
+ # @param base_url Base url used to resolve relative paths to resources (css, images, javascript, etc). Must be a http:// or https:// publicly available url.
2352
+ # @return The resulted hybrid invoice PDF.
2353
+ def create_from_html_string_with_base_url(html_string, base_url)
2354
+ prepare_invoice_html(html_string, base_url)
2355
+ @parameters['async'] = 'False'
2356
+
2357
+ perform_post_as_multipart_formdata
2358
+ end
2359
+
2360
+ # Create a hybrid electronic invoice from a raw HTML string and write it to an output stream.
2361
+ #
2362
+ # @param html_string The invoice HTML.
2363
+ # @param stream The output stream where the resulted PDF will be written.
2364
+ def create_from_html_string_to_stream(html_string, stream)
2365
+ create_from_html_string_to_stream_with_base_url(html_string, nil, stream)
2366
+ end
2367
+
2368
+ # Create a hybrid electronic invoice from a raw HTML string and write it to an output stream. Use a base url to resolve relative paths to resources.
2369
+ #
2370
+ # @param html_string The invoice HTML.
2371
+ # @param base_url Base url used to resolve relative paths to resources (css, images, javascript, etc). Must be a http:// or https:// publicly available url.
2372
+ # @param stream The output stream where the resulted PDF will be written.
2373
+ def create_from_html_string_to_stream_with_base_url(html_string, base_url, stream)
2374
+ prepare_invoice_html(html_string, base_url)
2375
+ @parameters['async'] = 'False'
2376
+
2377
+ perform_post_as_multipart_formdata(stream)
2378
+ end
2379
+
2380
+ # Create a hybrid electronic invoice from a raw HTML string and write it to a local file.
2381
+ #
2382
+ # @param html_string The invoice HTML.
2383
+ # @param file_path Local file including path if necessary.
2384
+ def create_from_html_string_to_file(html_string, file_path)
2385
+ create_from_html_string_with_base_url_to_file(html_string, nil, file_path)
2386
+ end
2387
+
2388
+ # Create a hybrid electronic invoice from a raw HTML string and write it to a local file. Use a base url to resolve relative paths to resources.
2389
+ #
2390
+ # @param html_string The invoice HTML.
2391
+ # @param base_url Base url used to resolve relative paths to resources (css, images, javascript, etc). Must be a http:// or https:// publicly available url.
2392
+ # @param file_path Local file including path if necessary.
2393
+ def create_from_html_string_with_base_url_to_file(html_string, base_url, file_path)
2394
+ write_invoice_to_file(file_path) do |file|
2395
+ create_from_html_string_to_stream_with_base_url(html_string, base_url, file)
2396
+ end
2397
+ end
2398
+
2399
+ # Create a hybrid electronic invoice from the invoice page at the specified url, using an asynchronous call.
2400
+ # Recommended for long invoice pages or callers that cannot hold an HTTP connection open for the whole conversion.
2401
+ #
2402
+ # @param url Url of the invoice page.
2403
+ # @return The resulted hybrid invoice PDF.
2404
+ def create_from_url_async(url)
2405
+ prepare_invoice_url(url)
2406
+ run_invoice_async_job
2407
+ end
2408
+
2409
+ # Create a hybrid electronic invoice from the invoice page at the specified url, using an asynchronous call, and write it to an output stream.
2410
+ #
2411
+ # @param url Url of the invoice page.
2412
+ # @param stream The output stream where the resulted PDF will be written.
2413
+ def create_from_url_to_stream_async(url, stream)
2414
+ result = create_from_url_async(url)
2415
+ stream.write(result)
2416
+ end
2417
+
2418
+ # Create a hybrid electronic invoice from the invoice page at the specified url, using an asynchronous call, and write it to a local file.
2419
+ #
2420
+ # @param url Url of the invoice page.
2421
+ # @param file_path Local file including path if necessary.
2422
+ def create_from_url_to_file_async(url, file_path)
2423
+ result = create_from_url_async(url)
2424
+ File.open(file_path, 'wb') do |file|
2425
+ file.write(result)
2426
+ end
2427
+ end
2428
+
2429
+ # Create a hybrid electronic invoice from a raw HTML string, using an asynchronous call.
2430
+ #
2431
+ # @param html_string The invoice HTML.
2432
+ # @return The resulted hybrid invoice PDF.
2433
+ def create_from_html_string_async(html_string)
2434
+ create_from_html_string_with_base_url_async(html_string, nil)
2435
+ end
2436
+
2437
+ # Create a hybrid electronic invoice from a raw HTML string, using an asynchronous call. Use a base url to resolve relative paths to resources.
2438
+ #
2439
+ # @param html_string The invoice HTML.
2440
+ # @param base_url Base url used to resolve relative paths to resources (css, images, javascript, etc). Must be a http:// or https:// publicly available url.
2441
+ # @return The resulted hybrid invoice PDF.
2442
+ def create_from_html_string_with_base_url_async(html_string, base_url)
2443
+ prepare_invoice_html(html_string, base_url)
2444
+ run_invoice_async_job
2445
+ end
2446
+
2447
+ # Create a hybrid electronic invoice from a raw HTML string, using an asynchronous call, and write it to an output stream.
2448
+ #
2449
+ # @param html_string The invoice HTML.
2450
+ # @param stream The output stream where the resulted PDF will be written.
2451
+ def create_from_html_string_to_stream_async(html_string, stream)
2452
+ result = create_from_html_string_async(html_string)
2453
+ stream.write(result)
2454
+ end
2455
+
2456
+ # Create a hybrid electronic invoice from a raw HTML string, using an asynchronous call, and write it to a local file.
2457
+ #
2458
+ # @param html_string The invoice HTML.
2459
+ # @param file_path Local file including path if necessary.
2460
+ def create_from_html_string_to_file_async(html_string, file_path)
2461
+ result = create_from_html_string_async(html_string)
2462
+ File.open(file_path, 'wb') do |file|
2463
+ file.write(result)
2464
+ end
2465
+ end
2466
+
2467
+ # Validate the url and set the url parameters.
2468
+ def prepare_invoice_url(url)
2469
+ if !url.downcase.start_with?('http://') && !url.downcase.start_with?('https://')
2470
+ raise ApiException.new('The supported protocols for the converted webpage are http:// and https://.'), 'The supported protocols for the converted webpage are http:// and https://.'
2471
+ end
2472
+
2473
+ if url.downcase.start_with?('http://localhost')
2474
+ raise ApiException.new('Cannot convert local urls. SelectPdf online API can only convert publicly available urls.'), 'Cannot convert local urls. SelectPdf online API can only convert publicly available urls.'
2475
+ end
2476
+
2477
+ require_invoice_xml
2478
+
2479
+ @parameters['url'] = url
2480
+ @parameters.delete('html')
2481
+ @parameters.delete('base_url')
2482
+ end
2483
+ private :prepare_invoice_url
2484
+
2485
+ # Set the html parameters.
2486
+ def prepare_invoice_html(html_string, base_url)
2487
+ require_invoice_xml
2488
+
2489
+ @parameters.delete('url')
2490
+ @parameters['html'] = html_string
2491
+
2492
+ if base_url.nil? || base_url.empty?
2493
+ @parameters.delete('base_url')
2494
+ else
2495
+ @parameters['base_url'] = base_url
2496
+ end
2497
+ end
2498
+ private :prepare_invoice_html
2499
+
2500
+ # Fail here rather than spending a round trip on a request the API will reject with the same message.
2501
+ def require_invoice_xml
2502
+ unless @files.key?('zugferd_xml') || @binary_data.key?('zugferd_xml')
2503
+ raise ApiException.new('The invoice XML was not specified. Set invoice_xml_file or invoice_xml before creating the invoice.'),
2504
+ 'The invoice XML was not specified. Set invoice_xml_file or invoice_xml before creating the invoice.'
2505
+ end
2506
+
2507
+ profile = @parameters['zugferd_profile']
2508
+ if profile.nil? || profile.to_s.empty?
2509
+ raise ApiException.new('The invoice profile was not specified. Set zugferd_profile before creating the invoice.'),
2510
+ 'The invoice profile was not specified. Set zugferd_profile before creating the invoice.'
2511
+ end
2512
+ end
2513
+ private :require_invoice_xml
2514
+
2515
+ # Run the invoice request as an asynchronous job and wait for its result.
2516
+ def run_invoice_async_job
2517
+ job_id = start_async_job_multipart_form_data
2518
+
2519
+ if job_id.nil? || job_id.empty?
2520
+ raise ApiException.new('An error occurred launching the asynchronous call.'), 'An error occurred launching the asynchronous call.'
2521
+ end
2522
+
2523
+ no_pings = 0
2524
+
2525
+ while no_pings < @async_calls_max_pings
2526
+ no_pings += 1
2527
+
2528
+ # sleep for a few seconds before next ping
2529
+ sleep(@async_calls_ping_interval)
2530
+
2531
+ async_job_client = AsyncJobClient.new(@parameters['key'], job_id)
2532
+ async_job_client.api_endpoint = @api_async_endpoint
2533
+
2534
+ result = async_job_client.result
2535
+
2536
+ next unless async_job_client.finished?
2537
+
2538
+ copy_async_job_results(async_job_client)
2539
+ return result
2540
+ end
2541
+
2542
+ raise ApiException.new('Asynchronous call did not finish in expected timeframe.'), 'Asynchronous call did not finish in expected timeframe.'
2543
+ end
2544
+ private :run_invoice_async_job
2545
+
2546
+ # Write the result of a conversion to a local file. The file is deleted if the conversion fails.
2547
+ def write_invoice_to_file(file_path)
2548
+ File.open(file_path, 'wb') do |file|
2549
+ yield file
2550
+ end
2551
+ rescue ApiException
2552
+ FileUtils.rm(file_path) if File.exist?(file_path)
2553
+ raise
2554
+ end
2555
+ private :write_invoice_to_file
2556
+ end
2557
+
2558
+ # Get the locations of certain web elements.
2559
+ # This is retrieved if pdf_web_elements_selectors parameter was set during the initial conversion call and elements were found to match the selectors.
2560
+ class WebElementsClient < ApiClient
2561
+ # Construct the web elements client.
2562
+ #
2563
+ # @param api_key API Key.
2564
+ # @param job_id Job ID.
2565
+ def initialize(api_key, job_id)
2566
+ super()
2567
+ @api_endpoint = 'https://selectpdf.com/api2/webelements/'
2568
+ @parameters['key'] = api_key
2569
+ @parameters['job_id'] = job_id
2570
+ end
2571
+
2572
+ # Get the locations of certain web elements.
1491
2573
  # This is retrieved if pdf_web_elements_selectors parameter is set and elements were found to match the selectors.
1492
2574
  #
1493
2575
  # @return List of web elements locations.
@@ -1520,9 +2602,895 @@ module SelectPdf
1520
2602
  def result
1521
2603
  result = perform_post
1522
2604
 
1523
- return result if @job_id.nil? || @job_id.empty?
2605
+ # 202 Accepted - the job is still running
2606
+ return nil unless finished?
2607
+
2608
+ result
2609
+ end
2610
+
2611
+ # Check if the asynchronous job is finished.
2612
+ # 200 OK means the job finished successfully, 202 Accepted means the job is still running.
2613
+ # Any other status code raises an ApiException from result (the job finished with an error).
2614
+ #
2615
+ # @return True if the job finished.
2616
+ def finished?
2617
+ @last_http_code != 202
2618
+ end
2619
+ end
2620
+
2621
+ # Pdf Merge with SelectPdf Online API.
2622
+ #
2623
+ # Code sample:
2624
+ #
2625
+ # require 'selectpdf'
2626
+ #
2627
+ # $stdout.sync = true
2628
+ #
2629
+ # print "This is SelectPdf-#{SelectPdf::CLIENT_VERSION}\n"
2630
+ #
2631
+ # test_url = 'https://selectpdf.com/demo/files/selectpdf.pdf'
2632
+ # test_pdf = 'Input.pdf'
2633
+ # local_file = 'Result.pdf'
2634
+ # api_key = 'Your API key here'
2635
+ #
2636
+ # begin
2637
+ # client = SelectPdf::PdfMergeClient.new(api_key)
2638
+ #
2639
+ # # set parameters - see full list at https://selectpdf.com/pdf-merge-api/
2640
+ #
2641
+ # # specify the pdf files that will be merged (order will be preserved in the final pdf)
2642
+ # client.add_file(test_pdf) # add PDF from local file
2643
+ # client.add_url_file(test_url) # add PDF from public url
2644
+ # # client.add_file(test_pdf, 'pdf_password') # add PDF (that requires a password) from local file
2645
+ # # client.add_url_file(test_url, 'pdf_password') # add PDF (that requires a password) from public url
2646
+ #
2647
+ # print "Starting pdf merge ...\n"
2648
+ #
2649
+ # # merge pdfs to local file
2650
+ # client.save_to_file(local_file)
2651
+ #
2652
+ # # merge pdfs to memory
2653
+ # # pdf = client.save
2654
+ #
2655
+ # print "Finished! Number of pages: #{client.number_of_pages}.\n"
2656
+ #
2657
+ # # get API usage
2658
+ # usage_client = SelectPdf::UsageClient.new(api_key)
2659
+ # usage = usage_client.get_usage(false)
2660
+ # print("Usage: #{usage}\n")
2661
+ # print('Conversions remained this month: ', usage['available'], "\n")
2662
+ # rescue SelectPdf::ApiException => e
2663
+ # print("An error occurred: #{e}")
2664
+ # end
2665
+ class PdfMergeClient < ApiClient
2666
+ # Construct the Pdf Merge Client.
2667
+ #
2668
+ # @param api_key API Key.
2669
+ def initialize(api_key)
2670
+ super()
2671
+ @api_endpoint = 'https://selectpdf.com/api2/pdfmerge/'
2672
+ @parameters['key'] = api_key
2673
+
2674
+ @file_idx = 0
2675
+ end
2676
+
2677
+ # Add local PDF document to the list of input files.
2678
+ #
2679
+ # @param input_pdf Path to a local PDF file.
2680
+ # @param user_password User password for the PDF document (optional).
2681
+ def add_file(input_pdf, user_password = nil)
2682
+ @file_idx += 1
2683
+
2684
+ @files["file_#{@file_idx}"] = input_pdf
2685
+ @parameters.delete("url_#{@file_idx}")
2686
+
2687
+ if user_password.nil? || user_password.empty?
2688
+ @parameters.delete("password_#{@file_idx}")
2689
+ else
2690
+ @parameters["password_#{@file_idx}"] = user_password
2691
+ end
2692
+ end
2693
+
2694
+ # Add remote PDF document to the list of input files.
2695
+ #
2696
+ # @param input_url Url of a remote PDF file.
2697
+ # @param user_password User password for the PDF document (optional).
2698
+ def add_url_file(input_url, user_password = nil)
2699
+ @file_idx += 1
2700
+
2701
+ @parameters["url_#{@file_idx}"] = input_url
2702
+ @files.delete("file_#{@file_idx}")
2703
+
2704
+ if user_password.nil? || user_password.empty?
2705
+ @parameters.delete("password_#{@file_idx}")
2706
+ else
2707
+ @parameters["password_#{@file_idx}"] = user_password
2708
+ end
2709
+ end
2710
+
2711
+ # Merge all specified input pdfs and return the resulted PDF.
2712
+ #
2713
+ # @return Byte array containing the resulted PDF.
2714
+ def save
2715
+ @parameters['async'] = 'False'
2716
+ @parameters['files_no'] = @file_idx
2717
+
2718
+ result = perform_post_as_multipart_formdata
2719
+
2720
+ @file_idx = 0
2721
+ @files = {}
2722
+
2723
+ result
2724
+ end
2725
+
2726
+ # Merge all specified input pdfs and writes the resulted PDF to a specified stream.
2727
+ #
2728
+ # @param stream The output stream where the resulted PDF will be written.
2729
+ def save_to_stream(stream)
2730
+ result = save
2731
+ stream.write(result)
2732
+ end
2733
+
2734
+ # Merge all specified input pdfs and writes the resulted PDF to a local file.
2735
+ #
2736
+ # @param file_path Local file including path if necessary.
2737
+ def save_to_file(file_path)
2738
+ result = save
2739
+ File.open(file_path, 'wb') do |file|
2740
+ file.write(result)
2741
+ end
2742
+ rescue ApiException
2743
+ FileUtils.rm(file_path) if File.exist?(file_path)
2744
+ raise
2745
+ end
2746
+
2747
+ # Merge all specified input pdfs and return the resulted PDF. An asynchronous call is used.
2748
+ #
2749
+ # @return Byte array containing the resulted PDF.
2750
+ def save_async
2751
+ @parameters['files_no'] = @file_idx
2752
+
2753
+ job_id = start_async_job_multipart_form_data
2754
+
2755
+ if job_id.nil? || job_id.empty?
2756
+ raise ApiException.new('An error occurred launching the asynchronous call.'), 'An error occurred launching the asynchronous call.'
2757
+ end
2758
+
2759
+ no_pings = 0
2760
+
2761
+ while no_pings < @async_calls_max_pings
2762
+ no_pings += 1
2763
+
2764
+ # sleep for a few seconds before next ping
2765
+ sleep(@async_calls_ping_interval)
2766
+
2767
+ async_job_client = AsyncJobClient.new(@parameters['key'], @job_id)
2768
+ async_job_client.api_endpoint = @api_async_endpoint
2769
+
2770
+ result = async_job_client.result
1524
2771
 
1525
- return nil
2772
+ next if result.nil?
2773
+
2774
+ copy_async_job_results(async_job_client)
2775
+ @file_idx = 0
2776
+ @files = {}
2777
+
2778
+ return result
2779
+ end
2780
+
2781
+ @file_idx = 0
2782
+ @files = {}
2783
+
2784
+ raise ApiException.new('Asynchronous call did not finish in expected timeframe.'), 'Asynchronous call did not finish in expected timeframe.'
2785
+ end
2786
+
2787
+ # Merge all specified input pdfs and writes the resulted PDF to a specified stream. An asynchronous call is used.
2788
+ #
2789
+ # @param stream The output stream where the resulted PDF will be written.
2790
+ def save_to_stream_async(stream)
2791
+ result = save_async
2792
+ stream.write(result)
2793
+ end
2794
+
2795
+ # Merge all specified input pdfs and writes the resulted PDF to a local file. An asynchronous call is used.
2796
+ #
2797
+ # @param file_path Local file including path if necessary.
2798
+ def save_to_file_async(file_path)
2799
+ result = save_async
2800
+ File.open(file_path, 'wb') do |file|
2801
+ file.write(result)
2802
+ end
2803
+ rescue ApiException
2804
+ FileUtils.rm(file_path) if File.exist?(file_path)
2805
+ raise
2806
+ end
2807
+
2808
+ # Set the PDF document title.
2809
+ #
2810
+ # @param doc_title Document title.
2811
+ def doc_title=(doc_title)
2812
+ @parameters['doc_title'] = doc_title
2813
+ end
2814
+
2815
+ # Set the subject of the PDF document.
2816
+ #
2817
+ # @param doc_subject Document subject.
2818
+ def doc_subject=(doc_subject)
2819
+ @parameters['doc_subject'] = doc_subject
2820
+ end
2821
+
2822
+ # Set the PDF document keywords.
2823
+ #
2824
+ # @param doc_keywords Document keywords.
2825
+ def doc_keywords=(doc_keywords)
2826
+ @parameters['doc_keywords'] = doc_keywords
2827
+ end
2828
+
2829
+ # Set the name of the PDF document author.
2830
+ #
2831
+ # @param doc_author Document author.
2832
+ def doc_author=(doc_author)
2833
+ @parameters['doc_author'] = doc_author
2834
+ end
2835
+
2836
+ # Add the date and time when the PDF document was created to the PDF document information. The default value is False.
2837
+ #
2838
+ # @param doc_add_creation_date Add creation date to the document metadata or not.
2839
+ def doc_add_creation_date=(doc_add_creation_date)
2840
+ @parameters['doc_add_creation_date'] = doc_add_creation_date
2841
+ end
2842
+
2843
+ # Set the page layout to be used when the document is opened in a PDF viewer. The default value is SelectPdf::PageLayout::ONE_COLUMN.
2844
+ #
2845
+ # @param viewer_page_layout Page layout. Possible values: 0 (Single Page), 1 (One Column), 2 (Two Column Left), 3 (Two Column Right).
2846
+ # Use constants from SelectPdf::PageLayout class.
2847
+ def viewer_page_layout=(viewer_page_layout)
2848
+ unless [0, 1, 2, 3].include?(viewer_page_layout)
2849
+ raise ApiException.new('Allowed values for Page Layout: 0 (Single Page), 1 (One Column), 2 (Two Column Left), 3 (Two Column Right).'), 'Allowed values for Page Layout: 0 (Single Page), 1 (One Column), 2 (Two Column Left), 3 (Two Column Right).'
2850
+ end
2851
+
2852
+ @parameters['viewer_page_layout'] = viewer_page_layout
2853
+ end
2854
+
2855
+ # Set the document page mode when the pdf document is opened in a PDF viewer. The default value is SelectPdf::PageMode::USE_NONE.
2856
+ #
2857
+ # @param viewer_page_mode Page mode. Possible values: 0 (Use None), 1 (Use Outlines), 2 (Use Thumbs), 3 (Full Screen), 4 (Use OC), 5 (Use Attachments).
2858
+ # Use constants from SelectPdf::PageMode class.
2859
+ def viewer_page_mode=(viewer_page_mode)
2860
+ unless [0, 1, 2, 3, 4, 5].include?(viewer_page_mode)
2861
+ raise ApiException.new('Allowed values for Page Mode: 0 (Use None), 1 (Use Outlines), 2 (Use Thumbs), 3 (Full Screen), 4 (Use OC), 5 (Use Attachments).'),
2862
+ 'Allowed values for Page Mode: 0 (Use None), 1 (Use Outlines), 2 (Use Thumbs), 3 (Full Screen), 4 (Use OC), 5 (Use Attachments).'
2863
+ end
2864
+
2865
+ @parameters['viewer_page_mode'] = viewer_page_mode
2866
+ end
2867
+
2868
+ # Set a flag specifying whether to position the document's window in the center of the screen. The default value is False.
2869
+ #
2870
+ # @param viewer_center_window Center window or not.
2871
+ def viewer_center_window=(viewer_center_window)
2872
+ @parameters['viewer_center_window'] = viewer_center_window
2873
+ end
2874
+
2875
+ # Set a flag specifying whether the window's title bar should display the document title taken from document information. The default value is False.
2876
+ #
2877
+ # @param viewer_display_doc_title Display title or not.
2878
+ def viewer_display_doc_title=(viewer_display_doc_title)
2879
+ @parameters['viewer_display_doc_title'] = viewer_display_doc_title
2880
+ end
2881
+
2882
+ # Set a flag specifying whether to resize the document's window to fit the size of the first displayed page. The default value is False.
2883
+ #
2884
+ # @param viewer_fit_window Fit window or not.
2885
+ def viewer_fit_window=(viewer_fit_window)
2886
+ @parameters['viewer_fit_window'] = viewer_fit_window
2887
+ end
2888
+
2889
+ # Set a flag specifying whether to hide the pdf viewer application's menu bar when the document is active. The default value is False.
2890
+ #
2891
+ # @param viewer_hide_menu_bar Hide menu bar or not.
2892
+ def viewer_hide_menu_bar=(viewer_hide_menu_bar)
2893
+ @parameters['viewer_hide_menu_bar'] = viewer_hide_menu_bar
2894
+ end
2895
+
2896
+ # Set a flag specifying whether to hide the pdf viewer application's tool bars when the document is active. The default value is False.
2897
+ #
2898
+ # @param viewer_hide_toolbar Hide tool bars or not.
2899
+ def viewer_hide_toolbar=(viewer_hide_toolbar)
2900
+ @parameters['viewer_hide_toolbar'] = viewer_hide_toolbar
2901
+ end
2902
+
2903
+ # Set a flag specifying whether to hide user interface elements in the document's window (such as scroll bars and navigation controls), leaving only the document's contents displayed.
2904
+ #
2905
+ # @param viewer_hide_window_ui Hide window UI or not.
2906
+ def viewer_hide_window_ui=(viewer_hide_window_ui)
2907
+ @parameters['viewer_hide_window_ui'] = viewer_hide_window_ui
2908
+ end
2909
+
2910
+ # Set PDF user password.
2911
+ #
2912
+ # @param user_password PDF user password.
2913
+ def user_password=(user_password)
2914
+ @parameters['user_password'] = user_password
2915
+ end
2916
+
2917
+ # Set PDF owner password.
2918
+ #
2919
+ # @param owner_password PDF owner password.
2920
+ def owner_password=(owner_password)
2921
+ @parameters['owner_password'] = owner_password
2922
+ end
2923
+
2924
+ # Set the maximum amount of time (in seconds) for this job.
2925
+ # The default value is 30 seconds. Use a larger value (up to 120 seconds allowed) for large documents.
2926
+ #
2927
+ # @param timeout Timeout in seconds.
2928
+ def timeout=(timeout)
2929
+ @parameters['timeout'] = timeout
2930
+ end
2931
+
2932
+ # Set a custom parameter. Do not use this method unless advised by SelectPdf.
2933
+ #
2934
+ # @param parameter_name Parameter name.
2935
+ # @param parameter_value Parameter value.
2936
+ def set_custom_parameter(parameter_name, parameter_value)
2937
+ @parameters[parameter_name] = parameter_value
2938
+ end
2939
+ end
2940
+
2941
+ # Pdf To Text Conversion with SelectPdf Online API.
2942
+ #
2943
+ # Code Sample for PDF To Text
2944
+ #
2945
+ # require 'selectpdf'
2946
+ #
2947
+ # $stdout.sync = true
2948
+ #
2949
+ # print "This is SelectPdf-#{SelectPdf::CLIENT_VERSION}\n"
2950
+ #
2951
+ # test_url = 'https://selectpdf.com/demo/files/selectpdf.pdf'
2952
+ # test_pdf = 'Input.pdf'
2953
+ # local_file = 'Result.txt'
2954
+ # api_key = 'Your API key here'
2955
+ #
2956
+ # begin
2957
+ # client = SelectPdf::PdfToTextClient.new(api_key)
2958
+ #
2959
+ # # set parameters - see full list at https://selectpdf.com/pdf-to-text-api/
2960
+ # client.start_page = 1 # start page (processing starts from here)
2961
+ # client.end_page = 0 # end page (set 0 to process file til the end)
2962
+ # client.output_format = SelectPdf::OutputFormat::TEXT # set output format (Text or HTML)
2963
+ #
2964
+ # print "Starting pdf to text ...\n"
2965
+ #
2966
+ # # convert local pdf to local text file
2967
+ # client.text_from_file_to_file(test_pdf, local_file)
2968
+ #
2969
+ # # extract text from local pdf to memory
2970
+ # # text = client.text_from_file(test_pdf)
2971
+ # # print text
2972
+ #
2973
+ # # convert pdf from public url to local text file
2974
+ # # client.text_from_url_to_file(test_url, local_file)
2975
+ #
2976
+ # # extract text from pdf from public url to memory
2977
+ # # text = client.text_from_url(test_url)
2978
+ # # print text
2979
+ #
2980
+ # print "Finished! Number of pages processed: #{client.number_of_pages}.\n"
2981
+ #
2982
+ # # get API usage
2983
+ # usage_client = SelectPdf::UsageClient.new(api_key)
2984
+ # usage = usage_client.get_usage(false)
2985
+ # print("Usage: #{usage}\n")
2986
+ # print('Conversions remained this month: ', usage['available'], "\n")
2987
+ # rescue SelectPdf::ApiException => e
2988
+ # print("An error occurred: #{e}")
2989
+ # end
2990
+ #
2991
+ # Code Sample for Search Pdf
2992
+ #
2993
+ # require 'selectpdf'
2994
+ #
2995
+ # $stdout.sync = true
2996
+ #
2997
+ # print "This is SelectPdf-#{SelectPdf::CLIENT_VERSION}\n"
2998
+ #
2999
+ # test_url = 'https://selectpdf.com/demo/files/selectpdf.pdf'
3000
+ # test_pdf = 'Input.pdf'
3001
+ # api_key = 'Your API key here'
3002
+ #
3003
+ # begin
3004
+ # client = SelectPdf::PdfToTextClient.new(api_key)
3005
+ #
3006
+ # # set parameters - see full list at https://selectpdf.com/pdf-to-text-api/
3007
+ # client.start_page = 1 # start page (processing starts from here)
3008
+ # client.end_page = 0 # end page (set 0 to process file til the end)
3009
+ # client.output_format = SelectPdf::OutputFormat::TEXT # set output format (Text or HTML)
3010
+ #
3011
+ # print "Starting search pdf ...\n"
3012
+ #
3013
+ # # search local pdf
3014
+ # results = client.search_file(test_pdf, 'pdf')
3015
+ #
3016
+ # # search pdf from public url
3017
+ # # results = client.search_url(test_url, 'pdf')
3018
+ #
3019
+ # print "Search results: #{results}.\nSearch results count: #{results.length}\n"
3020
+ #
3021
+ # print "Finished! Number of pages processed: #{client.number_of_pages}.\n"
3022
+ #
3023
+ # # get API usage
3024
+ # usage_client = SelectPdf::UsageClient.new(api_key)
3025
+ # usage = usage_client.get_usage(false)
3026
+ # print("Usage: #{usage}\n")
3027
+ # print('Conversions remained this month: ', usage['available'], "\n")
3028
+ # rescue SelectPdf::ApiException => e
3029
+ # print("An error occurred: #{e}")
3030
+ # end
3031
+ #
3032
+ class PdfToTextClient < ApiClient
3033
+ # Construct the Pdf To Text Client.
3034
+ #
3035
+ # @param api_key API Key.
3036
+ def initialize(api_key)
3037
+ super()
3038
+ @api_endpoint = 'https://selectpdf.com/api2/pdftotext/'
3039
+ @parameters['key'] = api_key
3040
+
3041
+ @file_idx = 0
3042
+ end
3043
+
3044
+ # Get the text from the specified pdf.
3045
+ #
3046
+ # @param input_pdf Path to a local PDF file.
3047
+ # @return Extracted text.
3048
+ def text_from_file(input_pdf)
3049
+ @parameters['async'] = 'False'
3050
+ @parameters['action'] = 'Convert'
3051
+ @parameters.delete('url')
3052
+
3053
+ @files = {}
3054
+ @files['inputPdf'] = input_pdf
3055
+
3056
+ perform_post_as_multipart_formdata
3057
+ end
3058
+
3059
+ # Get the text from the specified pdf and write it to the specified text file.
3060
+ #
3061
+ # @param input_pdf Path to a local PDF file.
3062
+ # @param output_file_path The output file where the resulted text will be written.
3063
+ def text_from_file_to_file(input_pdf, output_file_path)
3064
+ result = text_from_file(input_pdf)
3065
+ File.open(output_file_path, 'wb') do |file|
3066
+ file.write(result)
3067
+ end
3068
+ rescue ApiException
3069
+ FileUtils.rm(output_file_path) if File.exist?(output_file_path)
3070
+ raise
3071
+ end
3072
+
3073
+ # Get the text from the specified pdf and write it to the specified stream.
3074
+ #
3075
+ # @param input_pdf Path to a local PDF file.
3076
+ # @param stream The output stream where the resulted PDF will be written.
3077
+ def text_from_file_to_stream(input_pdf, stream)
3078
+ result = text_from_file(input_pdf)
3079
+ stream.write(result)
3080
+ end
3081
+
3082
+ # Get the text from the specified pdf with an asynchronous call.
3083
+ #
3084
+ # @param input_pdf Path to a local PDF file.
3085
+ # @return Extracted text.
3086
+ def text_from_file_async(input_pdf)
3087
+ @parameters['action'] = 'Convert'
3088
+ @parameters.delete('url')
3089
+
3090
+ @files = {}
3091
+ @files['inputPdf'] = input_pdf
3092
+
3093
+ job_id = start_async_job_multipart_form_data
3094
+
3095
+ if job_id.nil? || job_id.empty?
3096
+ raise ApiException.new('An error occurred launching the asynchronous call.'), 'An error occurred launching the asynchronous call.'
3097
+ end
3098
+
3099
+ no_pings = 0
3100
+
3101
+ while no_pings < @async_calls_max_pings
3102
+ no_pings += 1
3103
+
3104
+ # sleep for a few seconds before next ping
3105
+ sleep(@async_calls_ping_interval)
3106
+
3107
+ async_job_client = AsyncJobClient.new(@parameters['key'], @job_id)
3108
+ async_job_client.api_endpoint = @api_async_endpoint
3109
+
3110
+ result = async_job_client.result
3111
+
3112
+ next if result.nil?
3113
+
3114
+ copy_async_job_results(async_job_client)
3115
+
3116
+ return result
3117
+ end
3118
+
3119
+ raise ApiException.new('Asynchronous call did not finish in expected timeframe.'), 'Asynchronous call did not finish in expected timeframe.'
3120
+ end
3121
+
3122
+ # Get the text from the specified pdf with an asynchronous call and write it to the specified text file.
3123
+ #
3124
+ # @param input_pdf Path to a local PDF file.
3125
+ # @param output_file_path The output file where the resulted text will be written.
3126
+ def text_from_file_to_file_async(input_pdf, output_file_path)
3127
+ result = text_from_file_async(input_pdf)
3128
+ File.open(output_file_path, 'wb') do |file|
3129
+ file.write(result)
3130
+ end
3131
+ rescue ApiException
3132
+ FileUtils.rm(output_file_path) if File.exist?(output_file_path)
3133
+ raise
3134
+ end
3135
+
3136
+ # Get the text from the specified pdf with an asynchronous call and write it to the specified stream.
3137
+ #
3138
+ # @param input_pdf Path to a local PDF file.
3139
+ # @param stream The output stream where the resulted PDF will be written.
3140
+ def text_from_file_to_stream_async(input_pdf, stream)
3141
+ result = text_from_file_async(input_pdf)
3142
+ stream.write(result)
3143
+ end
3144
+
3145
+ # Get the text from the specified pdf.
3146
+ #
3147
+ # @param url Address of the PDF file.
3148
+ # @return Extracted text.
3149
+ def text_from_url(url)
3150
+ if !url.downcase.start_with?('http://') && !url.downcase.start_with?('https://')
3151
+ raise ApiException.new('The supported protocols for the PDFs available online are http:// and https://.'),
3152
+ 'The supported protocols for the PDFs available online are http:// and https://.'
3153
+ end
3154
+
3155
+ if url.downcase.start_with?('http://localhost')
3156
+ raise ApiException.new('Cannot convert local urls via this method. Use getTextFromFile instead.'),
3157
+ 'Cannot convert local urls via this method. Use text_from_file instead.'
3158
+ end
3159
+
3160
+ @parameters['async'] = 'False'
3161
+ @parameters['action'] = 'Convert'
3162
+
3163
+ @files = {}
3164
+ @parameters['url'] = url
3165
+
3166
+ perform_post_as_multipart_formdata
3167
+ end
3168
+
3169
+ # Get the text from the specified pdf and write it to the specified text file.
3170
+ #
3171
+ # @param url Address of the PDF file.
3172
+ # @param output_file_path The output file where the resulted text will be written.
3173
+ def text_from_url_to_file(url, output_file_path)
3174
+ result = text_from_url(url)
3175
+ File.open(output_file_path, 'wb') do |file|
3176
+ file.write(result)
3177
+ end
3178
+ rescue ApiException
3179
+ FileUtils.rm(output_file_path) if File.exist?(output_file_path)
3180
+ raise
3181
+ end
3182
+
3183
+ # Get the text from the specified pdf and write it to the specified stream.
3184
+ #
3185
+ # @param url Address of the PDF file.
3186
+ # @param stream The output stream where the resulted PDF will be written.
3187
+ def text_from_url_to_stream(url, stream)
3188
+ result = text_from_url(url)
3189
+ stream.write(result)
3190
+ end
3191
+
3192
+ # Get the text from the specified pdf with an asynchronous call.
3193
+ #
3194
+ # @param url Address of the PDF file.
3195
+ # @return Extracted text.
3196
+ def text_from_url_async(url)
3197
+ if !url.downcase.start_with?('http://') && !url.downcase.start_with?('https://')
3198
+ raise ApiException.new('The supported protocols for the PDFs available online are http:// and https://.'),
3199
+ 'The supported protocols for the PDFs available online are http:// and https://.'
3200
+ end
3201
+
3202
+ if url.downcase.start_with?('http://localhost')
3203
+ raise ApiException.new('Cannot convert local urls via this method. Use getTextFromFile instead.'),
3204
+ 'Cannot convert local urls via this method. Use text_from_file_async instead.'
3205
+ end
3206
+
3207
+ @parameters['action'] = 'Convert'
3208
+
3209
+ @files = {}
3210
+ @parameters['url'] = url
3211
+
3212
+ job_id = start_async_job_multipart_form_data
3213
+
3214
+ if job_id.nil? || job_id.empty?
3215
+ raise ApiException.new('An error occurred launching the asynchronous call.'), 'An error occurred launching the asynchronous call.'
3216
+ end
3217
+
3218
+ no_pings = 0
3219
+
3220
+ while no_pings < @async_calls_max_pings
3221
+ no_pings += 1
3222
+
3223
+ # sleep for a few seconds before next ping
3224
+ sleep(@async_calls_ping_interval)
3225
+
3226
+ async_job_client = AsyncJobClient.new(@parameters['key'], @job_id)
3227
+ async_job_client.api_endpoint = @api_async_endpoint
3228
+
3229
+ result = async_job_client.result
3230
+
3231
+ next if result.nil?
3232
+
3233
+ copy_async_job_results(async_job_client)
3234
+
3235
+ return result
3236
+ end
3237
+
3238
+ raise ApiException.new('Asynchronous call did not finish in expected timeframe.'),
3239
+ 'Asynchronous call did not finish in expected timeframe.'
3240
+ end
3241
+
3242
+ # Get the text from the specified pdf with an asynchronous call and write it to the specified text file.
3243
+ #
3244
+ # @param url Address of the PDF file.
3245
+ # @param output_file_path The output file where the resulted text will be written.
3246
+ def text_from_url_to_file_async(url, output_file_path)
3247
+ result = text_from_url_async(url)
3248
+ File.open(output_file_path, 'wb') do |file|
3249
+ file.write(result)
3250
+ end
3251
+ rescue ApiException
3252
+ FileUtils.rm(output_file_path) if File.exist?(output_file_path)
3253
+ raise
3254
+ end
3255
+
3256
+ # Get the text from the specified pdf with an asynchronous call and write it to the specified stream.
3257
+ #
3258
+ # @param url Address of the PDF file.
3259
+ # @param stream The output stream where the resulted PDF will be written.
3260
+ def text_from_url_to_stream_async(url, stream)
3261
+ result = text_from_url_async(url)
3262
+ stream.write(result)
3263
+ end
3264
+
3265
+ # Search for a specific text in a PDF document.
3266
+ # Pages that participate to this operation are specified by start_page and end_page methods.
3267
+ #
3268
+ # @param input_pdf Path to a local PDF file.
3269
+ # @param text_to_search Text to search.
3270
+ # @param case_sensitive If the search is case sensitive or not.
3271
+ # @param whole_words_only If the search works on whole words or not.
3272
+ # @return List with text positions in the current PDF document.
3273
+ def search_file(input_pdf, text_to_search, case_sensitive = false, whole_words_only = false)
3274
+ if text_to_search.nil? || text_to_search.empty?
3275
+ raise ApiException.new('Search text cannot be empty.'), 'Search text cannot be empty.'
3276
+ end
3277
+
3278
+ @parameters['async'] = 'False'
3279
+ @parameters['action'] = 'Search'
3280
+ @parameters.delete('url')
3281
+ @parameters['search_text'] = text_to_search
3282
+ @parameters['case_sensitive'] = case_sensitive
3283
+ @parameters['whole_words_only'] = whole_words_only
3284
+
3285
+ @files = {}
3286
+ @files['inputPdf'] = input_pdf
3287
+
3288
+ @headers['Accept'] = 'text/json'
3289
+
3290
+ result = perform_post_as_multipart_formdata
3291
+ return [] if result.nil? || result.empty?
3292
+
3293
+ JSON.parse(result)
3294
+ end
3295
+
3296
+ # Search for a specific text in a PDF document with an asynchronous call.
3297
+ # Pages that participate to this operation are specified by start_page and end_page methods.
3298
+ #
3299
+ # @param input_pdf Path to a local PDF file.
3300
+ # @param text_to_search Text to search.
3301
+ # @param case_sensitive If the search is case sensitive or not.
3302
+ # @param whole_words_only If the search works on whole words or not.
3303
+ # @return List with text positions in the current PDF document.
3304
+ def search_file_async(input_pdf, text_to_search, case_sensitive = false, whole_words_only = false)
3305
+ if text_to_search.nil? || text_to_search.empty?
3306
+ raise ApiException.new('Search text cannot be empty.'), 'Search text cannot be empty.'
3307
+ end
3308
+
3309
+ @parameters['action'] = 'Search'
3310
+ @parameters.delete('url')
3311
+ @parameters['search_text'] = text_to_search
3312
+ @parameters['case_sensitive'] = case_sensitive
3313
+ @parameters['whole_words_only'] = whole_words_only
3314
+
3315
+ @files = {}
3316
+ @files['inputPdf'] = input_pdf
3317
+
3318
+ @headers['Accept'] = 'text/json'
3319
+
3320
+ job_id = start_async_job_multipart_form_data
3321
+
3322
+ if job_id.nil? || job_id.empty?
3323
+ raise ApiException.new('An error occurred launching the asynchronous call.'),
3324
+ 'An error occurred launching the asynchronous call.'
3325
+ end
3326
+
3327
+ no_pings = 0
3328
+
3329
+ while no_pings < @async_calls_max_pings
3330
+ no_pings += 1
3331
+
3332
+ # sleep for a few seconds before next ping
3333
+ sleep(@async_calls_ping_interval)
3334
+
3335
+ async_job_client = AsyncJobClient.new(@parameters['key'], @job_id)
3336
+ async_job_client.api_endpoint = @api_async_endpoint
3337
+
3338
+ result = async_job_client.result
3339
+
3340
+ next if result.nil?
3341
+
3342
+ copy_async_job_results(async_job_client)
3343
+ return [] if result.empty?
3344
+
3345
+ return JSON.parse(result)
3346
+ end
3347
+
3348
+ raise ApiException.new('Asynchronous call did not finish in expected timeframe.'),
3349
+ 'Asynchronous call did not finish in expected timeframe.'
3350
+ end
3351
+
3352
+ # Search for a specific text in a PDF document.
3353
+ # Pages that participate to this operation are specified by start_page and end_page methods.
3354
+ #
3355
+ # @param url Address of the PDF file.
3356
+ # @param text_to_search Text to search.
3357
+ # @param case_sensitive If the search is case sensitive or not.
3358
+ # @param whole_words_only If the search works on whole words or not.
3359
+ # @return List with text positions in the current PDF document.
3360
+ def search_url(url, text_to_search, case_sensitive = false, whole_words_only = false)
3361
+ if text_to_search.nil? || text_to_search.empty?
3362
+ raise ApiException.new('Search text cannot be empty.'), 'Search text cannot be empty.'
3363
+ end
3364
+
3365
+ @parameters['async'] = 'False'
3366
+ @parameters['action'] = 'Search'
3367
+ @parameters['search_text'] = text_to_search
3368
+ @parameters['case_sensitive'] = case_sensitive
3369
+ @parameters['whole_words_only'] = whole_words_only
3370
+
3371
+ @files = {}
3372
+ @parameters['url'] = url
3373
+
3374
+ @headers['Accept'] = 'text/json'
3375
+
3376
+ result = perform_post_as_multipart_formdata
3377
+ return [] if result.nil? || result.empty?
3378
+
3379
+ JSON.parse(result)
3380
+ end
3381
+
3382
+ # Search for a specific text in a PDF document with an asynchronous call.
3383
+ # Pages that participate to this operation are specified by start_page and end_page methods.
3384
+ #
3385
+ # @param url Address of the PDF file.
3386
+ # @param text_to_search Text to search.
3387
+ # @param case_sensitive If the search is case sensitive or not.
3388
+ # @param whole_words_only If the search works on whole words or not.
3389
+ # @return List with text positions in the current PDF document.
3390
+ def search_url_async(url, text_to_search, case_sensitive = false, whole_words_only = false)
3391
+ if text_to_search.nil? || text_to_search.empty?
3392
+ raise ApiException.new('Search text cannot be empty.'), 'Search text cannot be empty.'
3393
+ end
3394
+
3395
+ @parameters['action'] = 'Search'
3396
+ @parameters['search_text'] = text_to_search
3397
+ @parameters['case_sensitive'] = case_sensitive
3398
+ @parameters['whole_words_only'] = whole_words_only
3399
+
3400
+ @files = {}
3401
+ @parameters['url'] = url
3402
+
3403
+ @headers['Accept'] = 'text/json'
3404
+
3405
+ job_id = start_async_job_multipart_form_data
3406
+
3407
+ if job_id.nil? || job_id.empty?
3408
+ raise ApiException.new('An error occurred launching the asynchronous call.'),
3409
+ 'An error occurred launching the asynchronous call.'
3410
+ end
3411
+
3412
+ no_pings = 0
3413
+
3414
+ while no_pings < @async_calls_max_pings
3415
+ no_pings += 1
3416
+
3417
+ # sleep for a few seconds before next ping
3418
+ sleep(@async_calls_ping_interval)
3419
+
3420
+ async_job_client = AsyncJobClient.new(@parameters['key'], @job_id)
3421
+ async_job_client.api_endpoint = @api_async_endpoint
3422
+
3423
+ result = async_job_client.result
3424
+
3425
+ next if result.nil?
3426
+
3427
+ copy_async_job_results(async_job_client)
3428
+ return [] if result.empty?
3429
+
3430
+ return JSON.parse(result)
3431
+ end
3432
+
3433
+ raise ApiException.new('Asynchronous call did not finish in expected timeframe.'),
3434
+ 'Asynchronous call did not finish in expected timeframe.'
3435
+ end
3436
+
3437
+ # Set Start Page number. Default value is 1 (first page of the document).
3438
+ #
3439
+ # @param start_page Start page number (1-based).
3440
+ def start_page=(start_page)
3441
+ @parameters['start_page'] = start_page
3442
+ end
3443
+
3444
+ # Set End Page number. Default value is 0 (process till the last page of the document).
3445
+ #
3446
+ # @param end_page End page number (1-based).
3447
+ def end_page=(end_page)
3448
+ @parameters['end_page'] = end_page
3449
+ end
3450
+
3451
+ # Set PDF user password.
3452
+ #
3453
+ # @param user_password PDF user password.
3454
+ def user_password=(user_password)
3455
+ @parameters['user_password'] = user_password
3456
+ end
3457
+
3458
+ # Set the text layout. The default value is SelectPdf::TextLayout::ORIGINAL.
3459
+ #
3460
+ # @param text_layout The text layout. Possible values: Original, Reading. Use constants from SelectPdf::TextLayout class.
3461
+ def text_layout=(text_layout)
3462
+ unless [0, 1].include?(text_layout)
3463
+ raise ApiException.new('Allowed values for Text Layout: 0 (Original), 1 (Reading).'), 'Allowed values for Text Layout: 0 (Original), 1 (Reading).'
3464
+ end
3465
+
3466
+ @parameters['text_layout'] = text_layout
3467
+ end
3468
+
3469
+ # Set the output format. The default value is SelectPdf::OutputFormat::TEXT.
3470
+ #
3471
+ # @param output_format The output format. Possible values: Text, Html. Use constants from SelectPdf::OutputFormat class.
3472
+ def output_format=(output_format)
3473
+ unless [0, 1].include?(output_format)
3474
+ raise ApiException.new('Allowed values for Output Format: 0 (Text), 1 (Html).'), 'Allowed values for Output Format: 0 (Text), 1 (Html).'
3475
+ end
3476
+
3477
+ @parameters['output_format'] = output_format
3478
+ end
3479
+
3480
+ # Set the maximum amount of time (in seconds) for this job.
3481
+ # The default value is 30 seconds. Use a larger value (up to 120 seconds allowed) for large documents.
3482
+ #
3483
+ # @param timeout Timeout in seconds.
3484
+ def timeout=(timeout)
3485
+ @parameters['timeout'] = timeout
3486
+ end
3487
+
3488
+ # Set a custom parameter. Do not use this method unless advised by SelectPdf.
3489
+ #
3490
+ # @param parameter_name Parameter name.
3491
+ # @param parameter_value Parameter value.
3492
+ def set_custom_parameter(parameter_name, parameter_value)
3493
+ @parameters[parameter_name] = parameter_value
1526
3494
  end
1527
3495
  end
1528
3496
  end