selectpdf 1.4.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +13 -1
- data/README.md +112 -2
- data/lib/selectpdf.rb +1005 -132
- data/samples/accessible_pdf.rb +69 -0
- data/samples/async_conversion.rb +48 -0
- data/samples/electronic_invoice.rb +106 -0
- data/samples/html_to_pdf_headers_and_footers.rb +5 -5
- data/samples/html_to_pdf_main.rb +40 -16
- data/samples/pdf_merge.rb +1 -1
- data/samples/pdf_to_text.rb +1 -1
- data/samples/search_pdf.rb +1 -1
- data/samples/simple_html_string_to_pdf.rb +2 -2
- data/samples/simple_url_to_pdf.rb +2 -2
- data/samples/web_elements.rb +42 -0
- metadata +10 -7
data/lib/selectpdf.rb
CHANGED
|
@@ -22,8 +22,8 @@ require 'fileutils'
|
|
|
22
22
|
#
|
|
23
23
|
# api.page_size = SelectPdf::PageSize::A4
|
|
24
24
|
# api.margins = 0
|
|
25
|
-
# api.page_numbers =
|
|
26
|
-
# api.page_breaks_enhanced_algorithm =
|
|
25
|
+
# api.page_numbers = false
|
|
26
|
+
# api.page_breaks_enhanced_algorithm = true
|
|
27
27
|
#
|
|
28
28
|
# api.convert_url_to_file(url, local_file)
|
|
29
29
|
# rescue SelectPdf::ApiException => e
|
|
@@ -66,7 +66,7 @@ require 'fileutils'
|
|
|
66
66
|
#
|
|
67
67
|
# # get API usage
|
|
68
68
|
# usage_client = SelectPdf::UsageClient.new(api_key)
|
|
69
|
-
# usage = usage_client.get_usage(
|
|
69
|
+
# usage = usage_client.get_usage(false)
|
|
70
70
|
# print("Usage: #{usage}\n")
|
|
71
71
|
# print('Conversions remained this month: ', usage['available'], "\n")
|
|
72
72
|
# rescue SelectPdf::ApiException => e
|
|
@@ -114,7 +114,7 @@ require 'fileutils'
|
|
|
114
114
|
#
|
|
115
115
|
# # get API usage
|
|
116
116
|
# usage_client = SelectPdf::UsageClient.new(api_key)
|
|
117
|
-
# usage = usage_client.get_usage(
|
|
117
|
+
# usage = usage_client.get_usage(false)
|
|
118
118
|
# print("Usage: #{usage}\n")
|
|
119
119
|
# print('Conversions remained this month: ', usage['available'], "\n")
|
|
120
120
|
# rescue SelectPdf::ApiException => e
|
|
@@ -155,7 +155,7 @@ require 'fileutils'
|
|
|
155
155
|
#
|
|
156
156
|
# # get API usage
|
|
157
157
|
# usage_client = SelectPdf::UsageClient.new(api_key)
|
|
158
|
-
# usage = usage_client.get_usage(
|
|
158
|
+
# usage = usage_client.get_usage(false)
|
|
159
159
|
# print("Usage: #{usage}\n")
|
|
160
160
|
# print('Conversions remained this month: ', usage['available'], "\n")
|
|
161
161
|
# rescue SelectPdf::ApiException => e
|
|
@@ -170,7 +170,10 @@ module SelectPdf
|
|
|
170
170
|
NEW_LINE = "\r\n"
|
|
171
171
|
|
|
172
172
|
# Library version
|
|
173
|
-
CLIENT_VERSION = '1.
|
|
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/'
|
|
174
177
|
|
|
175
178
|
attr_reader :code, :message
|
|
176
179
|
#
|
|
@@ -190,6 +193,115 @@ module SelectPdf
|
|
|
190
193
|
end
|
|
191
194
|
end
|
|
192
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
|
+
|
|
193
305
|
# Base class for API clients. Do not use this directly.
|
|
194
306
|
class ApiClient
|
|
195
307
|
# API endpoint
|
|
@@ -225,6 +337,22 @@ module SelectPdf
|
|
|
225
337
|
# Number of pages of the pdf document resulted from the conversion.
|
|
226
338
|
attr_reader :number_of_pages
|
|
227
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
|
+
|
|
228
356
|
# Class constructor
|
|
229
357
|
def initialize
|
|
230
358
|
# API endpoint
|
|
@@ -254,6 +382,15 @@ module SelectPdf
|
|
|
254
382
|
# Job ID for asynchronous calls or for calls that require a second request.
|
|
255
383
|
@job_id = ''
|
|
256
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
|
+
|
|
257
394
|
# Ping interval in seconds for asynchronous calls. Default value is 3 seconds.
|
|
258
395
|
@async_calls_ping_interval = 3
|
|
259
396
|
|
|
@@ -266,32 +403,62 @@ module SelectPdf
|
|
|
266
403
|
# @param out_stream Output response to this stream, if specified.
|
|
267
404
|
# @return If output stream is not specified, return response.
|
|
268
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)
|
|
269
437
|
# reset results
|
|
270
|
-
|
|
271
|
-
|
|
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
|
|
272
445
|
|
|
273
|
-
uri = URI(api_endpoint)
|
|
274
446
|
http = Net::HTTP.new(uri.host, uri.port)
|
|
275
447
|
http.use_ssl = @api_endpoint.downcase.start_with?('https')
|
|
276
448
|
http.read_timeout = 600 # timeout in seconds 600s=10minutes
|
|
277
449
|
|
|
278
450
|
http.start do |connection|
|
|
279
|
-
request = Net::HTTP::Post.new uri.request_uri
|
|
280
|
-
request.set_form_data(@parameters)
|
|
281
|
-
|
|
282
|
-
# add headers
|
|
283
|
-
request['selectpdf-api-client'] = "ruby-#{RUBY_VERSION}-#{CLIENT_VERSION}"
|
|
284
|
-
request['Content-Type'] = 'application/x-www-form-urlencoded'
|
|
285
|
-
@headers.each do |key, value|
|
|
286
|
-
request[key] = value
|
|
287
|
-
end
|
|
288
|
-
|
|
289
451
|
connection.request request do |response|
|
|
290
|
-
|
|
291
|
-
|
|
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)
|
|
292
460
|
# all ok
|
|
293
|
-
|
|
294
|
-
@job_id = response['selectpdf-api-jobid']
|
|
461
|
+
read_response_headers(response)
|
|
295
462
|
|
|
296
463
|
return response.body unless out_stream # return response if out_stream is not provided
|
|
297
464
|
|
|
@@ -299,14 +466,20 @@ module SelectPdf
|
|
|
299
466
|
response.read_body do |chunk|
|
|
300
467
|
out_stream.write chunk
|
|
301
468
|
end
|
|
302
|
-
when Net::HTTPAccepted
|
|
303
|
-
# request accepted (for asynchronous jobs)
|
|
304
|
-
@job_id = response['selectpdf-api-jobid']
|
|
305
469
|
|
|
306
470
|
return nil
|
|
307
471
|
else
|
|
308
472
|
# error - get error message
|
|
309
|
-
|
|
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
|
|
310
483
|
end
|
|
311
484
|
end
|
|
312
485
|
end
|
|
@@ -315,112 +488,167 @@ module SelectPdf
|
|
|
315
488
|
rescue SocketError => e
|
|
316
489
|
raise ApiException.new("Socket Error: #{e}"), "Socket Error: #{e}"
|
|
317
490
|
rescue Timeout::Error
|
|
318
|
-
raise ApiException.new(
|
|
491
|
+
raise ApiException.new('Connection Timeout: 600s exceeded'), 'Connection Timeout: 600s exceeded'
|
|
319
492
|
rescue OpenSSL::SSL::SSLError => e
|
|
320
493
|
raise ApiException.new("SSL Error: #{e}"), "SSL Error: #{e}"
|
|
321
494
|
rescue StandardError => e
|
|
322
495
|
raise ApiException.new("Connection refused: #{e}"), "Connection refused: #{e}"
|
|
323
496
|
end
|
|
324
|
-
|
|
497
|
+
private :execute_request
|
|
325
498
|
|
|
326
|
-
#
|
|
327
|
-
|
|
328
|
-
# @param out_stream Output response to this stream, if specified.
|
|
329
|
-
# @return If output stream is not specified, return response.
|
|
330
|
-
def perform_post_as_multipart_formdata(out_stream = nil)
|
|
331
|
-
# reset results
|
|
499
|
+
# Reset the results of the previous call.
|
|
500
|
+
def reset_results
|
|
332
501
|
@number_of_pages = 0
|
|
333
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
|
|
334
510
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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?
|
|
339
517
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
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?
|
|
343
520
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
request['Content-Type'] = "multipart/form-data; boundary=#{MULTIPART_FORM_DATA_BOUNDARY}"
|
|
347
|
-
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'])
|
|
348
523
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
end
|
|
524
|
+
mode = response['X-SelectPdf-Mode']
|
|
525
|
+
@mode = mode unless mode.nil? || mode.empty?
|
|
352
526
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
when Net::HTTPSuccess
|
|
356
|
-
# all ok
|
|
357
|
-
@number_of_pages = (response['selectpdf-api-pages'] || 0).to_i
|
|
358
|
-
@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?
|
|
359
529
|
|
|
360
|
-
|
|
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
|
|
361
538
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
@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
|
|
369
545
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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)
|
|
376
602
|
end
|
|
377
|
-
rescue ApiException
|
|
378
|
-
raise
|
|
379
|
-
rescue SocketError => e
|
|
380
|
-
raise ApiException.new("Socket Error: #{e}"), "Socket Error: #{e}"
|
|
381
|
-
rescue Timeout::Error
|
|
382
|
-
raise ApiException.new("Connection Timeout: #{http.read_timeout}s exceeded"), "Connection Timeout: #{http.read_timeout}s exceeded"
|
|
383
|
-
rescue OpenSSL::SSL::SSLError => e
|
|
384
|
-
raise ApiException.new("SSL Error: #{e}"), "SSL Error: #{e}"
|
|
385
|
-
rescue StandardError => e
|
|
386
|
-
raise ApiException.new("Connection refused: #{e}"), "Connection refused: #{e}"
|
|
387
603
|
end
|
|
388
|
-
|
|
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
|
|
389
612
|
|
|
390
613
|
# Encode data for multipart POST
|
|
391
614
|
def encode_multipart_form_data
|
|
392
|
-
data =
|
|
615
|
+
data = String.new # binary (ASCII-8BIT) buffer
|
|
393
616
|
|
|
394
617
|
# encode regular parameters
|
|
395
618
|
@parameters.each do |key, value|
|
|
396
|
-
|
|
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
|
|
397
626
|
end
|
|
398
627
|
|
|
399
628
|
# encode files
|
|
400
629
|
@files.each do |key, value|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
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
|
|
408
636
|
end
|
|
409
637
|
|
|
410
638
|
# encode additional binary data
|
|
411
639
|
@binary_data.each do |key, value|
|
|
412
|
-
data <<
|
|
413
|
-
data <<
|
|
414
|
-
data <<
|
|
415
|
-
data <<
|
|
416
|
-
data << value.
|
|
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
|
|
417
646
|
end
|
|
418
647
|
|
|
419
648
|
# final boundary
|
|
420
|
-
data <<
|
|
421
|
-
data << ''
|
|
649
|
+
data << "--#{MULTIPART_FORM_DATA_BOUNDARY}--#{NEW_LINE}".b
|
|
422
650
|
|
|
423
|
-
data
|
|
651
|
+
data
|
|
424
652
|
end
|
|
425
653
|
private :encode_multipart_form_data
|
|
426
654
|
|
|
@@ -443,6 +671,18 @@ module SelectPdf
|
|
|
443
671
|
@job_id
|
|
444
672
|
end
|
|
445
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
|
|
446
686
|
end
|
|
447
687
|
|
|
448
688
|
# Get usage details for SelectPdf Online API.
|
|
@@ -533,6 +773,9 @@ module SelectPdf
|
|
|
533
773
|
|
|
534
774
|
# Blink rendering engine.
|
|
535
775
|
BLINK = 'Blink'
|
|
776
|
+
|
|
777
|
+
# Chromium rendering engine.
|
|
778
|
+
CHROMIUM = 'Chromium'
|
|
536
779
|
end
|
|
537
780
|
|
|
538
781
|
# Protocol used for secure (HTTPS) connections.
|
|
@@ -622,6 +865,87 @@ module SelectPdf
|
|
|
622
865
|
HTML = 1
|
|
623
866
|
end
|
|
624
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
|
+
|
|
625
949
|
# Html To Pdf Conversion with SelectPdf Online API.
|
|
626
950
|
#
|
|
627
951
|
# Code sample:
|
|
@@ -647,18 +971,18 @@ module SelectPdf
|
|
|
647
971
|
# client.rendering_engine = SelectPdf::RenderingEngine::WEBKIT # rendering engine
|
|
648
972
|
# client.conversion_delay = 1 # conversion delay
|
|
649
973
|
# client.navigation_timeout = 30 # navigation timeout
|
|
650
|
-
# client.page_numbers =
|
|
651
|
-
# client.page_breaks_enhanced_algorithm =
|
|
974
|
+
# client.page_numbers = false # page numbers
|
|
975
|
+
# client.page_breaks_enhanced_algorithm = true # enhanced page break algorithm
|
|
652
976
|
#
|
|
653
977
|
# # additional properties
|
|
654
978
|
#
|
|
655
|
-
# # client.use_css_print =
|
|
656
|
-
# # client.disable_javascript =
|
|
657
|
-
# # client.disable_internal_links =
|
|
658
|
-
# # client.disable_external_links =
|
|
659
|
-
# # client.keep_images_together =
|
|
660
|
-
# # client.scale_images =
|
|
661
|
-
# # client.single_page_pdf =
|
|
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
|
|
662
986
|
# # client.user_password = 'password' # secure the PDF with a password
|
|
663
987
|
#
|
|
664
988
|
# # generate automatic bookmarks
|
|
@@ -684,21 +1008,98 @@ module SelectPdf
|
|
|
684
1008
|
#
|
|
685
1009
|
# # get API usage
|
|
686
1010
|
# usage_client = SelectPdf::UsageClient.new(api_key)
|
|
687
|
-
# usage = usage_client.get_usage(
|
|
1011
|
+
# usage = usage_client.get_usage(false)
|
|
688
1012
|
# print("Usage: #{usage}\n")
|
|
689
1013
|
# print('Conversions remained this month: ', usage['available'], "\n")
|
|
690
1014
|
# rescue SelectPdf::ApiException => e
|
|
691
1015
|
# print("An error occurred: #{e}")
|
|
692
1016
|
# end
|
|
693
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
|
+
|
|
694
1029
|
# Construct the Html To Pdf Client.
|
|
695
1030
|
#
|
|
696
|
-
#
|
|
697
|
-
|
|
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)
|
|
698
1036
|
super()
|
|
699
|
-
|
|
700
|
-
@
|
|
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'])
|
|
701
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
|
|
702
1103
|
|
|
703
1104
|
# Convert the specified url to PDF.
|
|
704
1105
|
# SelectPdf online API can convert http:// and https:// publicly available urls.
|
|
@@ -778,7 +1179,10 @@ module SelectPdf
|
|
|
778
1179
|
#
|
|
779
1180
|
# @param url Address of the web page being converted.
|
|
780
1181
|
# @return The resulted pdf.
|
|
1182
|
+
# Raises DemoUnsupportedException in demo mode (the demo endpoint does not run asynchronous jobs).
|
|
781
1183
|
def convert_url_async(url)
|
|
1184
|
+
ensure_async_supported
|
|
1185
|
+
|
|
782
1186
|
if !url.downcase.start_with?('http://') && !url.downcase.start_with?('https://')
|
|
783
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://.'
|
|
784
1188
|
end
|
|
@@ -812,7 +1216,7 @@ module SelectPdf
|
|
|
812
1216
|
|
|
813
1217
|
next if result.nil?
|
|
814
1218
|
|
|
815
|
-
|
|
1219
|
+
copy_async_job_results(async_job_client)
|
|
816
1220
|
return result
|
|
817
1221
|
end
|
|
818
1222
|
|
|
@@ -896,7 +1300,10 @@ module SelectPdf
|
|
|
896
1300
|
#
|
|
897
1301
|
# @param html_string HTML string with the content being converted.
|
|
898
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).
|
|
899
1304
|
def convert_html_string_with_base_url_async(html_string, base_url)
|
|
1305
|
+
ensure_async_supported
|
|
1306
|
+
|
|
900
1307
|
@parameters.delete('url')
|
|
901
1308
|
@parameters['html'] = html_string
|
|
902
1309
|
@parameters['base_url'] = base_url unless base_url.nil? || base_url.empty?
|
|
@@ -922,7 +1329,7 @@ module SelectPdf
|
|
|
922
1329
|
|
|
923
1330
|
next if result.nil?
|
|
924
1331
|
|
|
925
|
-
|
|
1332
|
+
copy_async_job_results(async_job_client)
|
|
926
1333
|
return result
|
|
927
1334
|
end
|
|
928
1335
|
|
|
@@ -1086,26 +1493,68 @@ module SelectPdf
|
|
|
1086
1493
|
|
|
1087
1494
|
# Set the rendering engine used for the HTML to PDF conversion. Default value is WebKit.
|
|
1088
1495
|
#
|
|
1089
|
-
# @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.
|
|
1090
1497
|
def rendering_engine=(rendering_engine)
|
|
1091
|
-
unless /(?i)^(WebKit|Restricted|Blink)$/.match(rendering_engine)
|
|
1092
|
-
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.'
|
|
1093
1500
|
end
|
|
1094
1501
|
|
|
1095
1502
|
@parameters['engine'] = rendering_engine
|
|
1096
1503
|
end
|
|
1097
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
|
+
|
|
1098
1541
|
# Set PDF user password.
|
|
1542
|
+
# Not available in demo mode: raises DemoUnsupportedException if the client was constructed without an API key.
|
|
1099
1543
|
#
|
|
1100
1544
|
# @param user_password PDF user password.
|
|
1101
1545
|
def user_password=(user_password)
|
|
1546
|
+
raise DemoUnsupportedException.new(0, 'user_password') if @demo_mode && !(user_password.nil? || user_password.empty?)
|
|
1547
|
+
|
|
1102
1548
|
@parameters['user_password'] = user_password
|
|
1103
1549
|
end
|
|
1104
1550
|
|
|
1105
1551
|
# Set PDF owner password.
|
|
1552
|
+
# Not available in demo mode: raises DemoUnsupportedException if the client was constructed without an API key.
|
|
1106
1553
|
#
|
|
1107
1554
|
# @param owner_password PDF owner password.
|
|
1108
1555
|
def owner_password=(owner_password)
|
|
1556
|
+
raise DemoUnsupportedException.new(0, 'owner_password') if @demo_mode && !(owner_password.nil? || owner_password.empty?)
|
|
1557
|
+
|
|
1109
1558
|
@parameters['owner_password'] = owner_password
|
|
1110
1559
|
end
|
|
1111
1560
|
|
|
@@ -1124,6 +1573,17 @@ module SelectPdf
|
|
|
1124
1573
|
@parameters['web_page_height'] = web_page_height
|
|
1125
1574
|
end
|
|
1126
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
|
+
|
|
1127
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.
|
|
1128
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.
|
|
1129
1589
|
#
|
|
@@ -1667,10 +2127,39 @@ module SelectPdf
|
|
|
1667
2127
|
end
|
|
1668
2128
|
|
|
1669
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.
|
|
1670
2131
|
#
|
|
1671
|
-
# @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).
|
|
1672
2133
|
def cookies=(cookies)
|
|
1673
|
-
|
|
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
|
|
1674
2163
|
end
|
|
1675
2164
|
|
|
1676
2165
|
# Set a custom parameter. Do not use this method unless advised by SelectPdf.
|
|
@@ -1692,6 +2181,380 @@ module SelectPdf
|
|
|
1692
2181
|
end
|
|
1693
2182
|
end
|
|
1694
2183
|
|
|
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.
|
|
2230
|
+
#
|
|
2231
|
+
# @param api_key API Key.
|
|
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
|
|
2237
|
+
|
|
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
|
+
|
|
1695
2558
|
# Get the locations of certain web elements.
|
|
1696
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.
|
|
1697
2560
|
class WebElementsClient < ApiClient
|
|
@@ -1739,9 +2602,19 @@ module SelectPdf
|
|
|
1739
2602
|
def result
|
|
1740
2603
|
result = perform_post
|
|
1741
2604
|
|
|
1742
|
-
|
|
2605
|
+
# 202 Accepted - the job is still running
|
|
2606
|
+
return nil unless finished?
|
|
2607
|
+
|
|
2608
|
+
result
|
|
2609
|
+
end
|
|
1743
2610
|
|
|
1744
|
-
|
|
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
|
|
1745
2618
|
end
|
|
1746
2619
|
end
|
|
1747
2620
|
|
|
@@ -1783,7 +2656,7 @@ module SelectPdf
|
|
|
1783
2656
|
#
|
|
1784
2657
|
# # get API usage
|
|
1785
2658
|
# usage_client = SelectPdf::UsageClient.new(api_key)
|
|
1786
|
-
# usage = usage_client.get_usage(
|
|
2659
|
+
# usage = usage_client.get_usage(false)
|
|
1787
2660
|
# print("Usage: #{usage}\n")
|
|
1788
2661
|
# print('Conversions remained this month: ', usage['available'], "\n")
|
|
1789
2662
|
# rescue SelectPdf::ApiException => e
|
|
@@ -1898,7 +2771,7 @@ module SelectPdf
|
|
|
1898
2771
|
|
|
1899
2772
|
next if result.nil?
|
|
1900
2773
|
|
|
1901
|
-
|
|
2774
|
+
copy_async_job_results(async_job_client)
|
|
1902
2775
|
@file_idx = 0
|
|
1903
2776
|
@files = {}
|
|
1904
2777
|
|
|
@@ -2108,7 +2981,7 @@ module SelectPdf
|
|
|
2108
2981
|
#
|
|
2109
2982
|
# # get API usage
|
|
2110
2983
|
# usage_client = SelectPdf::UsageClient.new(api_key)
|
|
2111
|
-
# usage = usage_client.get_usage(
|
|
2984
|
+
# usage = usage_client.get_usage(false)
|
|
2112
2985
|
# print("Usage: #{usage}\n")
|
|
2113
2986
|
# print('Conversions remained this month: ', usage['available'], "\n")
|
|
2114
2987
|
# rescue SelectPdf::ApiException => e
|
|
@@ -2149,7 +3022,7 @@ module SelectPdf
|
|
|
2149
3022
|
#
|
|
2150
3023
|
# # get API usage
|
|
2151
3024
|
# usage_client = SelectPdf::UsageClient.new(api_key)
|
|
2152
|
-
# usage = usage_client.get_usage(
|
|
3025
|
+
# usage = usage_client.get_usage(false)
|
|
2153
3026
|
# print("Usage: #{usage}\n")
|
|
2154
3027
|
# print('Conversions remained this month: ', usage['available'], "\n")
|
|
2155
3028
|
# rescue SelectPdf::ApiException => e
|
|
@@ -2238,7 +3111,7 @@ module SelectPdf
|
|
|
2238
3111
|
|
|
2239
3112
|
next if result.nil?
|
|
2240
3113
|
|
|
2241
|
-
|
|
3114
|
+
copy_async_job_results(async_job_client)
|
|
2242
3115
|
|
|
2243
3116
|
return result
|
|
2244
3117
|
end
|
|
@@ -2357,7 +3230,7 @@ module SelectPdf
|
|
|
2357
3230
|
|
|
2358
3231
|
next if result.nil?
|
|
2359
3232
|
|
|
2360
|
-
|
|
3233
|
+
copy_async_job_results(async_job_client)
|
|
2361
3234
|
|
|
2362
3235
|
return result
|
|
2363
3236
|
end
|
|
@@ -2397,7 +3270,7 @@ module SelectPdf
|
|
|
2397
3270
|
# @param case_sensitive If the search is case sensitive or not.
|
|
2398
3271
|
# @param whole_words_only If the search works on whole words or not.
|
|
2399
3272
|
# @return List with text positions in the current PDF document.
|
|
2400
|
-
def search_file(input_pdf, text_to_search, case_sensitive =
|
|
3273
|
+
def search_file(input_pdf, text_to_search, case_sensitive = false, whole_words_only = false)
|
|
2401
3274
|
if text_to_search.nil? || text_to_search.empty?
|
|
2402
3275
|
raise ApiException.new('Search text cannot be empty.'), 'Search text cannot be empty.'
|
|
2403
3276
|
end
|
|
@@ -2428,7 +3301,7 @@ module SelectPdf
|
|
|
2428
3301
|
# @param case_sensitive If the search is case sensitive or not.
|
|
2429
3302
|
# @param whole_words_only If the search works on whole words or not.
|
|
2430
3303
|
# @return List with text positions in the current PDF document.
|
|
2431
|
-
def search_file_async(input_pdf, text_to_search, case_sensitive =
|
|
3304
|
+
def search_file_async(input_pdf, text_to_search, case_sensitive = false, whole_words_only = false)
|
|
2432
3305
|
if text_to_search.nil? || text_to_search.empty?
|
|
2433
3306
|
raise ApiException.new('Search text cannot be empty.'), 'Search text cannot be empty.'
|
|
2434
3307
|
end
|
|
@@ -2466,7 +3339,7 @@ module SelectPdf
|
|
|
2466
3339
|
|
|
2467
3340
|
next if result.nil?
|
|
2468
3341
|
|
|
2469
|
-
|
|
3342
|
+
copy_async_job_results(async_job_client)
|
|
2470
3343
|
return [] if result.empty?
|
|
2471
3344
|
|
|
2472
3345
|
return JSON.parse(result)
|
|
@@ -2484,7 +3357,7 @@ module SelectPdf
|
|
|
2484
3357
|
# @param case_sensitive If the search is case sensitive or not.
|
|
2485
3358
|
# @param whole_words_only If the search works on whole words or not.
|
|
2486
3359
|
# @return List with text positions in the current PDF document.
|
|
2487
|
-
def search_url(url, text_to_search, case_sensitive =
|
|
3360
|
+
def search_url(url, text_to_search, case_sensitive = false, whole_words_only = false)
|
|
2488
3361
|
if text_to_search.nil? || text_to_search.empty?
|
|
2489
3362
|
raise ApiException.new('Search text cannot be empty.'), 'Search text cannot be empty.'
|
|
2490
3363
|
end
|
|
@@ -2514,7 +3387,7 @@ module SelectPdf
|
|
|
2514
3387
|
# @param case_sensitive If the search is case sensitive or not.
|
|
2515
3388
|
# @param whole_words_only If the search works on whole words or not.
|
|
2516
3389
|
# @return List with text positions in the current PDF document.
|
|
2517
|
-
def search_url_async(url, text_to_search, case_sensitive =
|
|
3390
|
+
def search_url_async(url, text_to_search, case_sensitive = false, whole_words_only = false)
|
|
2518
3391
|
if text_to_search.nil? || text_to_search.empty?
|
|
2519
3392
|
raise ApiException.new('Search text cannot be empty.'), 'Search text cannot be empty.'
|
|
2520
3393
|
end
|
|
@@ -2551,7 +3424,7 @@ module SelectPdf
|
|
|
2551
3424
|
|
|
2552
3425
|
next if result.nil?
|
|
2553
3426
|
|
|
2554
|
-
|
|
3427
|
+
copy_async_job_results(async_job_client)
|
|
2555
3428
|
return [] if result.empty?
|
|
2556
3429
|
|
|
2557
3430
|
return JSON.parse(result)
|