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.
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 = FALSE
26
- # api.page_breaks_enhanced_algorithm = TRUE
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(FALSE)
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(FALSE)
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(FALSE)
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.4.0'
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
- @number_of_pages = 0
271
- @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
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
- case response
291
- 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)
292
460
  # all ok
293
- @number_of_pages = (response['selectpdf-api-pages'] || 0).to_i
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
- 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
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("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'
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
- protected :perform_post
497
+ private :execute_request
325
498
 
326
- # Create a multipart/form-data POST request (that can handle file uploads).
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
- uri = URI(api_endpoint)
336
- http = Net::HTTP.new(uri.host, uri.port)
337
- http.use_ssl = @api_endpoint.downcase.start_with?('https')
338
- 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?
339
517
 
340
- http.start do |connection|
341
- request = Net::HTTP::Post.new uri.request_uri
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
- # add headers
345
- request['selectpdf-api-client'] = "ruby-#{RUBY_VERSION}-#{CLIENT_VERSION}"
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
- @headers.each do |key, value|
350
- request[key] = value
351
- end
524
+ mode = response['X-SelectPdf-Mode']
525
+ @mode = mode unless mode.nil? || mode.empty?
352
526
 
353
- connection.request request do |response|
354
- case response
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
- 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
361
538
 
362
- # out_steam is provided - write to it
363
- response.read_body do |chunk|
364
- out_stream.write chunk
365
- end
366
- when Net::HTTPAccepted
367
- # request accepted (for asynchronous jobs)
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
- return nil
371
- else
372
- # error - get error message
373
- raise ApiException.new(response.body, response.code), response.body
374
- end
375
- 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)
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
- 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
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
- 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
397
626
  end
398
627
 
399
628
  # encode files
400
629
  @files.each do |key, value|
401
- File.open(value, 'rb') do |f|
402
- data << '--' + MULTIPART_FORM_DATA_BOUNDARY
403
- data << 'Content-Disposition: form-data; name="%s"; filename="%s"' % [key, value]
404
- data << 'Content-Type: application/octet-stream'
405
- data << ''
406
- data << f.read.force_encoding('UTF-8')
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 << '--' + MULTIPART_FORM_DATA_BOUNDARY
413
- data << 'Content-Disposition: form-data; name="%s"; filename="%s"' % [key, key]
414
- data << 'Content-Type: application/octet-stream'
415
- data << ''
416
- 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
417
646
  end
418
647
 
419
648
  # final boundary
420
- data << '--' + MULTIPART_FORM_DATA_BOUNDARY + '--'
421
- data << ''
649
+ data << "--#{MULTIPART_FORM_DATA_BOUNDARY}--#{NEW_LINE}".b
422
650
 
423
- data.join(NEW_LINE)
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 = FALSE # page numbers
651
- # client.page_breaks_enhanced_algorithm = TRUE # enhanced page break 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 = TRUE # enable CSS media print
656
- # # client.disable_javascript = TRUE # disable javascript
657
- # # client.disable_internal_links = TRUE # disable internal links
658
- # # client.disable_external_links = TRUE # disable external links
659
- # # client.keep_images_together = TRUE # keep images together
660
- # # client.scale_images = TRUE # scale images to create smaller pdfs
661
- # # client.single_page_pdf = TRUE # generate a 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(FALSE)
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
- # @param api_key API Key.
697
- 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)
698
1036
  super()
699
- @api_endpoint = 'https://selectpdf.com/api2/convert/'
700
- @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'])
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
- @number_of_pages = async_job_client.number_of_pages
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
- @number_of_pages = async_job_client.number_of_pages
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
- @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
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
- 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
1743
2610
 
1744
- return nil
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(FALSE)
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
- @number_of_pages = async_job_client.number_of_pages
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(FALSE)
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(FALSE)
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
- @number_of_pages = async_job_client.number_of_pages
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
- @number_of_pages = async_job_client.number_of_pages
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 = FALSE, whole_words_only = FALSE)
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 = FALSE, whole_words_only = FALSE)
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
- @number_of_pages = async_job_client.number_of_pages
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 = FALSE, whole_words_only = FALSE)
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 = FALSE, whole_words_only = FALSE)
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
- @number_of_pages = async_job_client.number_of_pages
3427
+ copy_async_job_results(async_job_client)
2555
3428
  return [] if result.empty?
2556
3429
 
2557
3430
  return JSON.parse(result)