bidi2pdf 0.1.12 → 0.1.14
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/.devcontainer/Dockerfile +53 -0
- data/.devcontainer/devcontainer.json +51 -0
- data/.devcontainer/docker-compose.yml +33 -0
- data/.rubocop.yml +1 -1
- data/.ruby-gemset +1 -1
- data/.ruby-version +1 -1
- data/CHANGELOG.md +115 -4
- data/README.md +12 -0
- data/docker/Dockerfile +2 -2
- data/docker/Dockerfile.slim +2 -2
- data/docker/chrome-driver/chrome.json +874 -0
- data/docker/entrypoint.sh +3 -3
- data/lib/bidi2pdf/bidi/browser_tab.rb +52 -0
- data/lib/bidi2pdf/bidi/commands/capture_screenshot.rb +33 -0
- data/lib/bidi2pdf/bidi/commands/set_viewport.rb +32 -0
- data/lib/bidi2pdf/bidi/commands.rb +2 -0
- data/lib/bidi2pdf/bidi/event_manager.rb +2 -2
- data/lib/bidi2pdf/chromedriver_manager.rb +11 -0
- data/lib/bidi2pdf/cli.rb +11 -1
- data/lib/bidi2pdf/launcher.rb +5 -3
- data/lib/bidi2pdf/process_tree.rb +2 -2
- data/lib/bidi2pdf/test_helpers/images.rb +8 -0
- data/lib/bidi2pdf/test_helpers/pdf_file_helper.rb +5 -1
- data/lib/bidi2pdf/test_helpers/pdf_reader_utils.rb +1 -2
- data/lib/bidi2pdf/test_helpers/pdf_text_sanitizer.rb +29 -109
- data/lib/bidi2pdf/test_helpers/testcontainers/chromedriver_container.rb +1 -1
- data/lib/bidi2pdf/test_helpers/testcontainers/chromedriver_test_helper.rb +46 -13
- data/lib/bidi2pdf/test_helpers/testcontainers/container_endpoint.rb +30 -0
- data/lib/bidi2pdf/test_helpers/testcontainers/shared_docker_network.rb +5 -1
- data/lib/bidi2pdf/test_helpers/testcontainers/testcontainers_refinement.rb +33 -0
- data/lib/bidi2pdf/test_helpers/testcontainers.rb +1 -0
- data/lib/bidi2pdf/version.rb +1 -1
- data/lib/bidi2pdf.rb +2 -0
- data/sig/bidi2pdf/bidi/add_headers_interceptor.rbs +16 -9
- data/sig/bidi2pdf/bidi/auth_interceptor.rbs +32 -7
- data/sig/bidi2pdf/bidi/browser.rbs +4 -31
- data/sig/bidi2pdf/bidi/browser_console_logger.rbs +59 -0
- data/sig/bidi2pdf/bidi/browser_tab.rbs +252 -25
- data/sig/bidi2pdf/bidi/client.rbs +95 -43
- data/sig/bidi2pdf/bidi/command_manager.rbs +5 -17
- data/sig/bidi2pdf/bidi/commands/add_intercept.rbs +16 -8
- data/sig/bidi2pdf/bidi/commands/base.rbs +49 -9
- data/sig/bidi2pdf/bidi/commands/browser_close.rbs +3 -1
- data/sig/bidi2pdf/bidi/commands/browser_create_user_context.rbs +2 -0
- data/sig/bidi2pdf/bidi/commands/browser_remove_user_context.rbs +19 -0
- data/sig/bidi2pdf/bidi/commands/browsing_context_close.rbs +8 -2
- data/sig/bidi2pdf/bidi/commands/browsing_context_navigate.rbs +12 -6
- data/sig/bidi2pdf/bidi/commands/browsing_context_print.rbs +10 -5
- data/sig/bidi2pdf/bidi/commands/cancel_auth.rbs +8 -2
- data/sig/bidi2pdf/bidi/commands/capture_screenshot.rbs +31 -0
- data/sig/bidi2pdf/bidi/commands/cdp_get_session.rbs +17 -0
- data/sig/bidi2pdf/bidi/commands/create_window.rbs +4 -0
- data/sig/bidi2pdf/bidi/commands/get_user_contexts.rbs +2 -0
- data/sig/bidi2pdf/bidi/commands/network_continue.rbs +4 -0
- data/sig/bidi2pdf/bidi/commands/page_print.rbs +28 -0
- data/sig/bidi2pdf/bidi/commands/print_parameters_validator.rbs +3 -0
- data/sig/bidi2pdf/bidi/commands/provide_credentials.rbs +12 -6
- data/sig/bidi2pdf/bidi/commands/script_evaluate.rbs +4 -0
- data/sig/bidi2pdf/bidi/commands/session_end.rbs +2 -0
- data/sig/bidi2pdf/bidi/commands/session_status.rbs +3 -1
- data/sig/bidi2pdf/bidi/commands/session_subscribe.rbs +4 -0
- data/sig/bidi2pdf/bidi/commands/set_tab_cookie.rbs +51 -23
- data/sig/bidi2pdf/bidi/commands/set_usercontext_cookie.rbs +17 -19
- data/sig/bidi2pdf/bidi/commands/set_viewport.rbs +31 -0
- data/sig/bidi2pdf/bidi/connection_manager.rbs +2 -1
- data/sig/bidi2pdf/bidi/event_manager.rbs +14 -19
- data/sig/bidi2pdf/bidi/interceptor.rbs +5 -0
- data/sig/bidi2pdf/bidi/js_logger_helper.rbs +9 -0
- data/sig/bidi2pdf/bidi/logger_events.rbs +21 -0
- data/sig/bidi2pdf/bidi/navigation_failed_events.rbs +17 -0
- data/sig/bidi2pdf/bidi/network_event.rbs +54 -46
- data/sig/bidi2pdf/bidi/network_event_formatters/network_event_console_formatter.rbs +63 -0
- data/sig/bidi2pdf/bidi/network_event_formatters/network_event_formatter_utils.rbs +15 -0
- data/sig/bidi2pdf/bidi/network_event_formatters/network_event_html_formatter.rbs +18 -0
- data/sig/bidi2pdf/bidi/network_event_formatters.rbs +6 -0
- data/sig/bidi2pdf/bidi/network_events.rbs +14 -39
- data/sig/bidi2pdf/bidi/session.rbs +144 -31
- data/sig/bidi2pdf/bidi/user_context.rbs +64 -45
- data/sig/bidi2pdf/bidi/web_socket_dispatcher.rbs +22 -30
- data/sig/bidi2pdf/chromedriver_manager.rbs +57 -22
- data/sig/bidi2pdf/cli.rbs +22 -2
- data/sig/bidi2pdf/dsl.rbs +34 -0
- data/sig/bidi2pdf/launcher.rbs +39 -1
- data/sig/bidi2pdf/notifications/event.rbs +40 -0
- data/sig/bidi2pdf/notifications/instrumenter.rbs +21 -0
- data/sig/bidi2pdf/notifications/logging_subscriber.rbs +41 -0
- data/sig/bidi2pdf/notifications.rbs +24 -0
- data/sig/bidi2pdf/process_tree.rbs +2 -2
- data/sig/bidi2pdf/session_runner.rbs +41 -1
- data/sig/bidi2pdf/test_helpers/configuration.rbs +51 -0
- data/sig/bidi2pdf/test_helpers/images/extractor.rbs +43 -0
- data/sig/bidi2pdf/test_helpers/images/image_similarity_checker.rbs +35 -0
- data/sig/bidi2pdf/test_helpers/images/tiff_helper.rbs +70 -0
- data/sig/bidi2pdf/test_helpers/images.rbs +0 -0
- data/sig/bidi2pdf/test_helpers/matchers/contains_pdf_image.rbs +0 -0
- data/sig/bidi2pdf/test_helpers/matchers/contains_pdf_text.rbs +0 -0
- data/sig/bidi2pdf/test_helpers/matchers/have_pdf_page_count.rbs +0 -0
- data/sig/bidi2pdf/test_helpers/matchers/match_pdf_text.rbs +0 -0
- data/sig/bidi2pdf/test_helpers/pdf_file_helper.rbs +23 -0
- data/sig/bidi2pdf/test_helpers/pdf_reader_utils.rbs +49 -0
- data/sig/bidi2pdf/test_helpers/pdf_text_sanitizer.rbs +38 -0
- data/sig/bidi2pdf/test_helpers/spec_paths_helper.rbs +35 -0
- data/sig/bidi2pdf/test_helpers/testcontainers/chromedriver_container.rbs +43 -0
- data/sig/bidi2pdf/test_helpers/testcontainers/chromedriver_test_helper.rbs +45 -0
- data/sig/bidi2pdf/test_helpers/testcontainers/container_endpoint.rbs +25 -0
- data/sig/bidi2pdf/test_helpers/testcontainers/shared_docker_network.rbs +3 -0
- data/sig/bidi2pdf/test_helpers/testcontainers/testcontainers_refinement.rbs +47 -0
- data/sig/bidi2pdf/test_helpers/testcontainers.rbs +6 -0
- data/sig/bidi2pdf/test_helpers.rbs +0 -0
- data/sig/bidi2pdf/verbose_logger.rbs +37 -0
- data/sig/bidi2pdf/version.rbs +3 -0
- data/sig/bidi2pdf.rbs +112 -2
- data/tasks/generate_rbs.rake +11 -11
- metadata +62 -10
- data/sig/bidi2pdf/utils.rbs +0 -5
data/docker/entrypoint.sh
CHANGED
|
@@ -45,10 +45,10 @@ fi
|
|
|
45
45
|
|
|
46
46
|
# DISPLAY=:99 /home/appuser/.webdrivers/chromedriver --port=33259 --whitelisted-ips="" --allowed-origins="*" --disable-dev-shm-usage --disable-gpu --verbose
|
|
47
47
|
/home/appuser/.webdrivers/chromedriver --port=${CHROMEDRIVER_PORT} \
|
|
48
|
-
--
|
|
49
|
-
--whitelisted-ips="" \
|
|
48
|
+
--allowed-ips="" \
|
|
50
49
|
--allowed-origins="*" \
|
|
51
50
|
--disable-dev-shm-usage \
|
|
52
51
|
--disable-gpu \
|
|
53
52
|
--user-data-dir=${USER_DATA_DIR} \
|
|
54
|
-
--
|
|
53
|
+
--log-level=${CHROMEDRIVER_LOG_LEVEL:-INFO} \
|
|
54
|
+
--readable-timestamp
|
|
@@ -444,6 +444,58 @@ module Bidi2pdf
|
|
|
444
444
|
|
|
445
445
|
# rubocop:enable Metrics/AbcSize, Metrics/PerceivedComplexity
|
|
446
446
|
|
|
447
|
+
# Captures a screenshot of the browser tab, mirroring #print's own
|
|
448
|
+
# file/block/base64-return trichotomy.
|
|
449
|
+
#
|
|
450
|
+
# @param [String, nil] outputfile The output file for the PNG. Defaults to nil.
|
|
451
|
+
# @param [String] origin "document" (default) or "viewport" - see browsingContext.captureScreenshot.
|
|
452
|
+
# @param [String, nil] format Image format override, e.g. "image/png". Defaults to the browser's own default.
|
|
453
|
+
# @param [Hash, nil] clip A clip rectangle/element, passed through verbatim. Defaults to nil (full capture).
|
|
454
|
+
# @yield [png_base64] A block to handle the PNG content.
|
|
455
|
+
# @return [String, nil] The base64-encoded PNG content, or nil if outputfile or block is provided.
|
|
456
|
+
# rubocop:disable Metrics/AbcSize
|
|
457
|
+
def screenshot(outputfile = nil, origin: "document", format: nil, clip: nil, &block)
|
|
458
|
+
Bidi2pdf.notification_service.instrument("screenshot.bidi2pdf") do |instrumentation_payload|
|
|
459
|
+
cmd = Commands::CaptureScreenshot.new(context: browsing_context_id, origin: origin, format: format, clip: clip)
|
|
460
|
+
|
|
461
|
+
instrumentation_payload[:cmd] = cmd
|
|
462
|
+
|
|
463
|
+
client.send_cmd_and_wait(cmd) do |response|
|
|
464
|
+
if response["result"]
|
|
465
|
+
png_base64 = response.dig("result", "data")
|
|
466
|
+
|
|
467
|
+
instrumentation_payload[:png_base64] = png_base64
|
|
468
|
+
|
|
469
|
+
if outputfile
|
|
470
|
+
raise ScreenshotError, "Folder does not exist: #{File.dirname(outputfile)}" unless File.directory?(File.dirname(outputfile))
|
|
471
|
+
|
|
472
|
+
File.binwrite(outputfile, Base64.decode64(png_base64))
|
|
473
|
+
Bidi2pdf.logger.info "Screenshot saved as '#{outputfile}'."
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
block.call(png_base64) if block_given?
|
|
477
|
+
|
|
478
|
+
return png_base64 unless outputfile || block_given?
|
|
479
|
+
else
|
|
480
|
+
Bidi2pdf.logger.error "Error capturing screenshot: #{response}"
|
|
481
|
+
end
|
|
482
|
+
end
|
|
483
|
+
end
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
# rubocop:enable Metrics/AbcSize
|
|
487
|
+
|
|
488
|
+
# Sets the browsing context's viewport - makes a headless render deterministic across
|
|
489
|
+
# machines, unlike leaving it at the browser's own default.
|
|
490
|
+
#
|
|
491
|
+
# @param [Integer] width
|
|
492
|
+
# @param [Integer] height
|
|
493
|
+
# @param [Float, nil] device_pixel_ratio Defaults to nil (browser default).
|
|
494
|
+
def set_viewport(width:, height:, device_pixel_ratio: nil)
|
|
495
|
+
cmd = Commands::SetViewport.new(context: browsing_context_id, width: width, height: height, device_pixel_ratio: device_pixel_ratio)
|
|
496
|
+
client.send_cmd_and_wait(cmd) { |response| response }
|
|
497
|
+
end
|
|
498
|
+
|
|
447
499
|
private
|
|
448
500
|
|
|
449
501
|
def build_command_and_extractor(print_options)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bidi2pdf
|
|
4
|
+
module Bidi
|
|
5
|
+
module Commands
|
|
6
|
+
class CaptureScreenshot
|
|
7
|
+
include Base
|
|
8
|
+
|
|
9
|
+
attr_reader :context, :origin, :format, :clip
|
|
10
|
+
|
|
11
|
+
def initialize(context:, origin: "document", format: nil, clip: nil)
|
|
12
|
+
@context = context
|
|
13
|
+
@origin = origin
|
|
14
|
+
@format = format
|
|
15
|
+
@clip = clip
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def method_name
|
|
19
|
+
"browsingContext.captureScreenshot"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def params
|
|
23
|
+
{
|
|
24
|
+
context: context,
|
|
25
|
+
origin: origin,
|
|
26
|
+
format: format,
|
|
27
|
+
clip: clip
|
|
28
|
+
}.compact
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bidi2pdf
|
|
4
|
+
module Bidi
|
|
5
|
+
module Commands
|
|
6
|
+
class SetViewport
|
|
7
|
+
include Base
|
|
8
|
+
|
|
9
|
+
attr_reader :context, :width, :height, :device_pixel_ratio
|
|
10
|
+
|
|
11
|
+
def initialize(context:, width:, height:, device_pixel_ratio: nil)
|
|
12
|
+
@context = context
|
|
13
|
+
@width = width
|
|
14
|
+
@height = height
|
|
15
|
+
@device_pixel_ratio = device_pixel_ratio
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def method_name
|
|
19
|
+
"browsingContext.setViewport"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def params
|
|
23
|
+
{
|
|
24
|
+
context: context,
|
|
25
|
+
viewport: { width: width, height: height },
|
|
26
|
+
devicePixelRatio: device_pixel_ratio
|
|
27
|
+
}.compact
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -18,6 +18,8 @@ module Bidi2pdf
|
|
|
18
18
|
require_relative "commands/browsing_context_close"
|
|
19
19
|
require_relative "commands/browsing_context_navigate"
|
|
20
20
|
require_relative "commands/browsing_context_print"
|
|
21
|
+
require_relative "commands/capture_screenshot"
|
|
22
|
+
require_relative "commands/set_viewport"
|
|
21
23
|
require_relative "commands/cdp_get_session"
|
|
22
24
|
require_relative "commands/page_print"
|
|
23
25
|
require_relative "commands/session_subscribe"
|
|
@@ -185,6 +185,8 @@ module Bidi2pdf
|
|
|
185
185
|
cmd = [bin]
|
|
186
186
|
cmd << "--port=#{@port}" unless @port.zero?
|
|
187
187
|
cmd << "--headless" if @headless
|
|
188
|
+
cmd << "--log-level=#{chromedriver_log_level}"
|
|
189
|
+
cmd << "--readable-timestamp"
|
|
188
190
|
|
|
189
191
|
cmd << user_data_dir_arg
|
|
190
192
|
cmd.concat(process_chrome_args)
|
|
@@ -192,6 +194,15 @@ module Bidi2pdf
|
|
|
192
194
|
cmd.join(" ")
|
|
193
195
|
end
|
|
194
196
|
|
|
197
|
+
def chromedriver_log_level
|
|
198
|
+
case Bidi2pdf.logger.level
|
|
199
|
+
when Logger::DEBUG then "ALL"
|
|
200
|
+
when Logger::INFO then "INFO"
|
|
201
|
+
when Logger::WARN then "WARNING"
|
|
202
|
+
else "SEVERE"
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
195
206
|
def user_data_dir_arg
|
|
196
207
|
provided = @chrome_args&.find { |arg| arg.include?("--user-data-dir") }
|
|
197
208
|
return provided if provided
|
data/lib/bidi2pdf/cli.rb
CHANGED
|
@@ -40,6 +40,8 @@ module Bidi2pdf
|
|
|
40
40
|
option :header, type: :array, default: [], banner: "name=value", desc: "One or more custom headers", aliases: "-H"
|
|
41
41
|
option :auth, type: :string, banner: "user:pass", desc: "Basic auth credentials", aliases: "-a"
|
|
42
42
|
option :headless, type: :boolean, default: true, desc: "Run Chrome in headless mode"
|
|
43
|
+
option :no_sandbox, type: :boolean, default: false, desc: "Disable Chrome sandbox (needed inside containers)"
|
|
44
|
+
option :chrome_flag, type: :array, default: [], banner: "--flag[=value]", desc: "Extra Chrome flags appended to defaults", aliases: "--cf"
|
|
43
45
|
option :port, type: :numeric, default: 0, desc: "Port to run ChromeDriver on (0 = auto)"
|
|
44
46
|
option :wait_window_loaded,
|
|
45
47
|
type: :boolean,
|
|
@@ -114,6 +116,8 @@ module Bidi2pdf
|
|
|
114
116
|
"url" => "https://example.com",
|
|
115
117
|
"output" => "output.pdf",
|
|
116
118
|
"headless" => true,
|
|
119
|
+
"no_sandbox" => false,
|
|
120
|
+
"chrome_flag" => [],
|
|
117
121
|
"print_options" => {
|
|
118
122
|
"background" => true,
|
|
119
123
|
"orientation" => "portrait",
|
|
@@ -210,6 +214,11 @@ module Bidi2pdf
|
|
|
210
214
|
@launcher ||= begin
|
|
211
215
|
username, password = parse_auth(merged_options[:auth]) if merged_options[:auth]
|
|
212
216
|
|
|
217
|
+
chrome_args = Bidi2pdf::Bidi::Session::DEFAULT_CHROME_ARGS.dup
|
|
218
|
+
no_sandbox = merged_options[:no_sandbox] || ENV["DISABLE_CHROME_SANDBOX"] == "true"
|
|
219
|
+
chrome_args << "--no-sandbox" if no_sandbox
|
|
220
|
+
chrome_args.concat(Array(merged_options[:chrome_flag]))
|
|
221
|
+
|
|
213
222
|
Bidi2pdf::Launcher.new(
|
|
214
223
|
url: merged_options[:url],
|
|
215
224
|
inputfile: merged_options[:hmtl_file],
|
|
@@ -223,7 +232,8 @@ module Bidi2pdf
|
|
|
223
232
|
wait_window_loaded: merged_options[:wait_window_loaded],
|
|
224
233
|
wait_network_idle: merged_options[:wait_network_idle],
|
|
225
234
|
print_options: print_options,
|
|
226
|
-
network_log_format: merged_options[:network_log_format]
|
|
235
|
+
network_log_format: merged_options[:network_log_format],
|
|
236
|
+
chrome_args: chrome_args
|
|
227
237
|
)
|
|
228
238
|
end
|
|
229
239
|
end
|
data/lib/bidi2pdf/launcher.rb
CHANGED
|
@@ -38,7 +38,8 @@ module Bidi2pdf
|
|
|
38
38
|
class Launcher
|
|
39
39
|
# rubocop:disable Metrics/ParameterLists
|
|
40
40
|
def initialize(url:, inputfile:, output:, cookies:, headers:, auth:, headless: true, port: 0, wait_window_loaded: false,
|
|
41
|
-
wait_network_idle: false, print_options: {}, remote_browser_url: nil, network_log_format: :console
|
|
41
|
+
wait_network_idle: false, print_options: {}, remote_browser_url: nil, network_log_format: :console,
|
|
42
|
+
chrome_args: Bidi::Session::DEFAULT_CHROME_ARGS)
|
|
42
43
|
@url = url
|
|
43
44
|
@inputfile = inputfile
|
|
44
45
|
@port = port
|
|
@@ -54,6 +55,7 @@ module Bidi2pdf
|
|
|
54
55
|
@remote_browser_url = remote_browser_url
|
|
55
56
|
@custom_session = nil
|
|
56
57
|
@network_log_format = network_log_format
|
|
58
|
+
@chrome_args = chrome_args
|
|
57
59
|
end
|
|
58
60
|
|
|
59
61
|
# rubocop:enable Metrics/ParameterLists
|
|
@@ -84,9 +86,9 @@ module Bidi2pdf
|
|
|
84
86
|
|
|
85
87
|
def session
|
|
86
88
|
if @remote_browser_url
|
|
87
|
-
@custom_session = Bidi::Session.new(session_url: @remote_browser_url, headless: @headless)
|
|
89
|
+
@custom_session = Bidi::Session.new(session_url: @remote_browser_url, headless: @headless, chrome_args: @chrome_args)
|
|
88
90
|
else
|
|
89
|
-
@manager = ChromedriverManager.new(port: @port, headless: @headless)
|
|
91
|
+
@manager = ChromedriverManager.new(port: @port, headless: @headless, chrome_args: @chrome_args)
|
|
90
92
|
@manager.start
|
|
91
93
|
@manager.session
|
|
92
94
|
end
|
|
@@ -36,8 +36,8 @@ module Bidi2pdf
|
|
|
36
36
|
end
|
|
37
37
|
|
|
38
38
|
def build_process_map
|
|
39
|
-
ProcTable.ps.
|
|
40
|
-
|
|
39
|
+
ProcTable.ps.to_h do |process|
|
|
40
|
+
[process.pid, { info: process, children: [] }]
|
|
41
41
|
end
|
|
42
42
|
end
|
|
43
43
|
|
|
@@ -6,6 +6,14 @@ rescue LoadError
|
|
|
6
6
|
warn "Missing #{dep}. Add it to your Gemfile if you're using Bidi2pdf image test helpers."
|
|
7
7
|
end
|
|
8
8
|
|
|
9
|
+
# CVE-2026-66066 (Rails Active Storage, 2026-07-29): libvips ships loaders/savers that were never
|
|
10
|
+
# fuzz-tested (SVG, JPEG XL, JPEG 2000, BMP, ICO, PSD, anything delegated to ImageMagick, ...) -
|
|
11
|
+
# left reachable, a crafted file can trigger arbitrary file read or RCE. Applied once, here, for
|
|
12
|
+
# every consumer of this opt-in module (Extractor, ImageSimilarityChecker, contains_pdf_image),
|
|
13
|
+
# rather than left to each call site to remember - PNG/JPEG (what this gem's own screenshot/PDF
|
|
14
|
+
# rendering actually produces) load through already-fuzzed loaders and are unaffected.
|
|
15
|
+
Vips.block_untrusted(true) if defined?(Vips)
|
|
16
|
+
|
|
9
17
|
require_relative "images/tiff_helper"
|
|
10
18
|
require_relative "images/extractor"
|
|
11
19
|
require_relative "images/image_similarity_checker"
|
|
@@ -5,6 +5,10 @@ module Bidi2pdf
|
|
|
5
5
|
# This module provides helper methods for handling PDF files in tests.
|
|
6
6
|
# It includes methods for debugging, storing, and managing PDF files.
|
|
7
7
|
module PdfFileHelper
|
|
8
|
+
def reporter
|
|
9
|
+
RSpec.configuration.reporter
|
|
10
|
+
end
|
|
11
|
+
|
|
8
12
|
# Executes a block with the given PDF data and handles debugging in case of test failures.
|
|
9
13
|
# If an expectation fails, the PDF data is saved to a file for debugging purposes.
|
|
10
14
|
# @param [String] pdf_data the PDF data to debug
|
|
@@ -14,7 +18,7 @@ module Bidi2pdf
|
|
|
14
18
|
yield pdf_data
|
|
15
19
|
rescue RSpec::Expectations::ExpectationNotMetError => e
|
|
16
20
|
failure_output = store_pdf_file pdf_data, "test-failure"
|
|
17
|
-
|
|
21
|
+
reporter.message("Test failed! PDF saved to: #{failure_output}")
|
|
18
22
|
raise e
|
|
19
23
|
end
|
|
20
24
|
|
|
@@ -49,7 +49,7 @@ module Bidi2pdf
|
|
|
49
49
|
# @param pdf_data [String, StringIO, File] The PDF data to be converted.
|
|
50
50
|
# @return [IO] An IO object containing the PDF data.
|
|
51
51
|
def convert_data_to_io(pdf_data)
|
|
52
|
-
# rubocop:disable Lint/DuplicateBranch
|
|
52
|
+
# rubocop:disable-next Lint/DuplicateBranch
|
|
53
53
|
if pdf_data.is_a?(String) && (pdf_data.start_with?("JVBERi") || pdf_data.start_with?("JVBER"))
|
|
54
54
|
StringIO.new(Base64.decode64(pdf_data))
|
|
55
55
|
elsif pdf_data.start_with?("%PDF-")
|
|
@@ -61,7 +61,6 @@ module Bidi2pdf
|
|
|
61
61
|
else
|
|
62
62
|
StringIO.new(pdf_data)
|
|
63
63
|
end
|
|
64
|
-
# rubocop:enable Lint/DuplicateBranch
|
|
65
64
|
end
|
|
66
65
|
end
|
|
67
66
|
|
|
@@ -6,26 +6,9 @@ require "diff/lcs/hunk"
|
|
|
6
6
|
|
|
7
7
|
module Bidi2pdf
|
|
8
8
|
module TestHelpers
|
|
9
|
-
# rubocop: disable Metrics/ModuleLength
|
|
10
|
-
# Provides utilities for sanitizing and comparing PDF text content.
|
|
11
|
-
# This module includes methods for cleaning text, comparing PDF content,
|
|
12
|
-
# and reporting differences between actual and expected PDF outputs.
|
|
13
|
-
#
|
|
14
|
-
# The sanitization process includes normalizing whitespace, replacing
|
|
15
|
-
# typographic ligatures, and handling other common text formatting issues.
|
|
16
|
-
#
|
|
17
|
-
# @example Cleaning text
|
|
18
|
-
# sanitized_text = Bidi2pdf::TestHelpers::PDFTextSanitizer.clean("Some text")
|
|
19
|
-
#
|
|
20
|
-
# @example Comparing PDF content
|
|
21
|
-
# match = Bidi2pdf::TestHelpers::PDFTextSanitizer.match?(actual_pdf, expected_pdf)
|
|
9
|
+
# rubocop: disable-next Metrics/ModuleLength
|
|
22
10
|
module PDFTextSanitizer
|
|
23
11
|
class << self
|
|
24
|
-
# Cleans the given text by replacing common typographic ligatures,
|
|
25
|
-
# normalizing whitespace, and removing unnecessary characters.
|
|
26
|
-
#
|
|
27
|
-
# @param [String] text The text to clean.
|
|
28
|
-
# @return [String] The cleaned text.
|
|
29
12
|
def clean(text)
|
|
30
13
|
text = UnicodeUtils.nfkd(text)
|
|
31
14
|
|
|
@@ -37,157 +20,96 @@ module Bidi2pdf
|
|
|
37
20
|
.gsub("…", "...")
|
|
38
21
|
.gsub("—", "--")
|
|
39
22
|
.gsub("–", "-")
|
|
40
|
-
.gsub(/\s+/, " ")
|
|
23
|
+
.gsub(/\s+/, " ")
|
|
41
24
|
.strip
|
|
42
25
|
end
|
|
43
26
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
#
|
|
47
|
-
# @param [Object] actual_pdf_thingy The PDF object to clean.
|
|
48
|
-
# @return [Array<String>] An array of cleaned page texts.
|
|
49
|
-
def clean_pages(actual_pdf_thingy)
|
|
50
|
-
Bidi2pdf::TestHelpers::PDFReaderUtils.pdf_text(actual_pdf_thingy).map { |text| clean(text) }
|
|
27
|
+
def clean_pages(pdf)
|
|
28
|
+
Bidi2pdf::TestHelpers::PDFReaderUtils.pdf_text(pdf).map { |text| clean(text) }
|
|
51
29
|
end
|
|
52
30
|
|
|
53
|
-
# Cleans the given text and removes all whitespace for comparison purposes.
|
|
54
|
-
#
|
|
55
|
-
# @param [String] text The text to clean and normalize.
|
|
56
|
-
# @return [String] The cleaned text without whitespace.
|
|
57
31
|
def normalize(text)
|
|
58
32
|
clean(text).gsub(/\s+/, "")
|
|
59
33
|
end
|
|
60
34
|
|
|
61
|
-
# Checks if the given PDF contains the expected text or pattern.
|
|
62
|
-
#
|
|
63
|
-
# @param [Object] actual_pdf_thingy The PDF object to search.
|
|
64
|
-
# @param [String, Regexp] expected The expected text or pattern.
|
|
65
|
-
# @param [Integer, nil] page_number The specific page to search (optional).
|
|
66
|
-
# @return [Boolean] `true` if the expected text is found, `false` otherwise.
|
|
67
35
|
def contains?(actual_pdf_thingy, expected, page_number = nil)
|
|
68
|
-
|
|
69
|
-
cleaned_pages = clean_pages(pages)
|
|
36
|
+
cleaned_pages = clean_pages(actual_pdf_thingy)
|
|
70
37
|
|
|
71
38
|
return false if page_number && page_number > cleaned_pages.size
|
|
72
39
|
|
|
73
|
-
# Narrow to specific page if requested
|
|
74
40
|
if page_number
|
|
75
41
|
text = cleaned_pages[page_number - 1]
|
|
76
42
|
return match_expected?(text, expected)
|
|
77
43
|
end
|
|
78
44
|
|
|
79
|
-
# Search all pages
|
|
80
45
|
cleaned_pages.any? { |page| match_expected?(page, expected) }
|
|
81
46
|
end
|
|
82
47
|
|
|
83
|
-
# Matches the given text against the expected text or pattern.
|
|
84
|
-
#
|
|
85
|
-
# @param [String] text The text to match.
|
|
86
|
-
# @param [String, Regexp] expected The expected text or pattern.
|
|
87
|
-
# @return [Boolean] `true` if the text matches, `false` otherwise.
|
|
88
48
|
def match_expected?(text, expected)
|
|
89
49
|
return false unless text
|
|
90
50
|
|
|
91
51
|
expected.is_a?(Regexp) ? text.match?(expected) : text.include?(expected.to_s)
|
|
92
52
|
end
|
|
93
53
|
|
|
94
|
-
# Compares the content of two PDF objects for equality.
|
|
95
|
-
#
|
|
96
|
-
# @param [Object] actual_pdf_thingy The actual PDF object.
|
|
97
|
-
# @param [Object] expected_pdf_thingy The expected PDF object.
|
|
98
|
-
# @return [Boolean] `true` if the content matches, `false` otherwise.
|
|
99
54
|
def match?(actual_pdf_thingy, expected_pdf_thingy)
|
|
100
|
-
|
|
101
|
-
|
|
55
|
+
cleaned_actual = clean_pages(actual_pdf_thingy)
|
|
56
|
+
cleaned_expected = clean_pages(expected_pdf_thingy)
|
|
102
57
|
|
|
103
|
-
cleaned_actual = clean_pages(actual)
|
|
104
|
-
cleaned_expected = clean_pages(expected)
|
|
105
|
-
|
|
106
|
-
# Compare without whitespace for equality check
|
|
107
58
|
actual_for_comparison = cleaned_actual.map { |text| normalize(text) }
|
|
108
59
|
expected_for_comparison = cleaned_expected.map { |text| normalize(text) }
|
|
109
60
|
|
|
110
|
-
if actual_for_comparison == expected_for_comparison
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
false
|
|
115
|
-
end
|
|
61
|
+
return true if actual_for_comparison == expected_for_comparison
|
|
62
|
+
|
|
63
|
+
report_content_mismatch(cleaned_actual, cleaned_expected)
|
|
64
|
+
false
|
|
116
65
|
end
|
|
117
66
|
|
|
118
|
-
# Reports differences between actual and expected PDF content.
|
|
119
|
-
#
|
|
120
|
-
# @param [Array<String>] actual The actual PDF content.
|
|
121
|
-
# @param [Array<String>] expected The expected PDF content.
|
|
122
|
-
# @return [void]
|
|
123
67
|
def report_content_mismatch(actual, expected)
|
|
124
|
-
|
|
125
|
-
print_differences(actual, expected)
|
|
68
|
+
reporter.message(content_mismatch_message(actual, expected))
|
|
126
69
|
end
|
|
127
70
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
# @param [Array<String>] actual The actual PDF content.
|
|
131
|
-
# @param [Array<String>] expected The expected PDF content.
|
|
132
|
-
# @return [void]
|
|
133
|
-
def print_differences(actual, expected)
|
|
71
|
+
def content_mismatch_message(actual, expected)
|
|
72
|
+
sections = ["--- PDF content mismatch ---"]
|
|
134
73
|
max_pages = [actual.length, expected.length].max
|
|
135
74
|
|
|
136
75
|
(0...max_pages).each do |page_idx|
|
|
137
76
|
actual_page = actual[page_idx] || "(missing page)"
|
|
138
77
|
expected_page = expected[page_idx] || "(missing page)"
|
|
139
78
|
|
|
140
|
-
|
|
79
|
+
page_message = page_difference_message(actual_page, expected_page, page_idx)
|
|
80
|
+
sections << page_message if page_message
|
|
141
81
|
end
|
|
82
|
+
|
|
83
|
+
sections.join("\n")
|
|
142
84
|
end
|
|
143
85
|
|
|
144
|
-
|
|
145
|
-
# This method compares the content ignoring whitespace and, if differences are found,
|
|
146
|
-
# outputs a formatted representation of those differences.
|
|
147
|
-
#
|
|
148
|
-
# @param [String] actual_page The actual page content.
|
|
149
|
-
# @param [String] expected_page The expected page content.
|
|
150
|
-
# @param [Integer] page_idx The zero-based index of the page being compared.
|
|
151
|
-
# @return [void]
|
|
152
|
-
def print_differences_for_page(actual_page, expected_page, page_idx)
|
|
153
|
-
# Compare without whitespace
|
|
86
|
+
def page_difference_message(actual_page, expected_page, page_idx)
|
|
154
87
|
actual_no_space = normalize(actual_page.to_s)
|
|
155
88
|
expected_no_space = normalize(expected_page.to_s)
|
|
156
89
|
|
|
157
|
-
return if actual_no_space == expected_no_space
|
|
158
|
-
|
|
159
|
-
puts "\nPage #{page_idx + 1} differences (ignoring whitespace):"
|
|
90
|
+
return nil if actual_no_space == expected_no_space
|
|
160
91
|
|
|
161
|
-
# Create diffs between the two pages
|
|
162
92
|
diffs = Diff::LCS.sdiff(expected_page, actual_page)
|
|
163
93
|
|
|
164
|
-
|
|
165
|
-
|
|
94
|
+
[
|
|
95
|
+
"",
|
|
96
|
+
"Page #{page_idx + 1} differences (ignoring whitespace):",
|
|
97
|
+
format_diff_output(diffs, expected_page, actual_page)
|
|
98
|
+
].join("\n")
|
|
166
99
|
end
|
|
167
100
|
|
|
168
|
-
# Formats the output of differences for display.
|
|
169
|
-
#
|
|
170
|
-
# @param [Array<Diff::LCS::ContextChange>] diffs The list of differences.
|
|
171
|
-
# @param [String] expected The expected text.
|
|
172
|
-
# @param [String] actual The actual text.
|
|
173
|
-
# @return [String] The formatted differences.
|
|
174
101
|
def format_diff_output(diffs, expected, actual)
|
|
175
|
-
output = []
|
|
176
|
-
|
|
177
102
|
changes = group_changed_diffs(diffs)
|
|
178
103
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
output += format_change expected, actual, change
|
|
182
|
-
end
|
|
104
|
+
changes.flat_map { |change| format_change(expected, actual, change) }.join("\n")
|
|
105
|
+
end
|
|
183
106
|
|
|
184
|
-
|
|
107
|
+
def reporter
|
|
108
|
+
RSpec.configuration.reporter
|
|
185
109
|
end
|
|
186
110
|
|
|
187
111
|
private
|
|
188
112
|
|
|
189
|
-
# Groups contiguous “real” diffs (added/removed/changed) into blocks,
|
|
190
|
-
# splitting whenever you hit an unchanged (“=”) diff.
|
|
191
113
|
def group_changed_diffs(diffs)
|
|
192
114
|
diffs
|
|
193
115
|
.chunk_while { |_prev, curr| curr.action != "=" }
|
|
@@ -212,7 +134,6 @@ module Bidi2pdf
|
|
|
212
134
|
}
|
|
213
135
|
end
|
|
214
136
|
|
|
215
|
-
# 3. Build the final lines of output
|
|
216
137
|
def build_output(snip_data, pos)
|
|
217
138
|
start = snip_data[:context_start]
|
|
218
139
|
ctx = snip_data[:context]
|
|
@@ -227,6 +148,5 @@ module Bidi2pdf
|
|
|
227
148
|
end
|
|
228
149
|
end
|
|
229
150
|
end
|
|
230
|
-
# rubocop:enable Metrics/ModuleLength
|
|
231
151
|
end
|
|
232
152
|
end
|
|
@@ -16,6 +16,10 @@ module Bidi2pdf
|
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
module SessionTestHelper
|
|
19
|
+
def reporter
|
|
20
|
+
RSpec.configuration.reporter
|
|
21
|
+
end
|
|
22
|
+
|
|
19
23
|
def chrome_args
|
|
20
24
|
chrome_args = Bidi2pdf::Bidi::Session::DEFAULT_CHROME_ARGS.dup
|
|
21
25
|
|
|
@@ -24,7 +28,7 @@ module Bidi2pdf
|
|
|
24
28
|
if ENV["DISABLE_CHROME_SANDBOX"]
|
|
25
29
|
chrome_args << "--no-sandbox"
|
|
26
30
|
|
|
27
|
-
|
|
31
|
+
reporter.message("🚨 Chrome sandbox disabled")
|
|
28
32
|
end
|
|
29
33
|
chrome_args
|
|
30
34
|
end
|
|
@@ -32,6 +36,24 @@ module Bidi2pdf
|
|
|
32
36
|
def create_session(session_url)
|
|
33
37
|
Bidi2pdf::Bidi::Session.new(session_url: session_url, headless: true, chrome_args: chrome_args)
|
|
34
38
|
end
|
|
39
|
+
|
|
40
|
+
# Full session/tab lifecycle in one call - create_session above only builds the session
|
|
41
|
+
# object, it never starts or closes it, and there is no after(:each) teardown for a spec
|
|
42
|
+
# that uses it directly. Mirrors Bidi2pdf::DSL.with_tab's own ensure-based teardown, but
|
|
43
|
+
# against a session_url this module already knows about (a long-lived remote-chrome
|
|
44
|
+
# reached over `session:` metadata, not a freshly spawned local chromedriver per call).
|
|
45
|
+
def with_tab(session_url)
|
|
46
|
+
session = create_session(session_url)
|
|
47
|
+
session.start
|
|
48
|
+
user_context = session.browser.create_user_context
|
|
49
|
+
tab = user_context.create_browser_window
|
|
50
|
+
|
|
51
|
+
yield tab
|
|
52
|
+
ensure
|
|
53
|
+
tab&.close
|
|
54
|
+
user_context&.close
|
|
55
|
+
session&.close
|
|
56
|
+
end
|
|
35
57
|
end
|
|
36
58
|
end
|
|
37
59
|
end
|
|
@@ -51,7 +73,7 @@ RSpec.configure do |config|
|
|
|
51
73
|
shared_network: config.shared_network
|
|
52
74
|
)
|
|
53
75
|
|
|
54
|
-
|
|
76
|
+
reporter.message("🚀 chromedriver container started for tests")
|
|
55
77
|
end
|
|
56
78
|
end
|
|
57
79
|
|
|
@@ -61,20 +83,31 @@ RSpec.configure do |config|
|
|
|
61
83
|
end
|
|
62
84
|
|
|
63
85
|
def stop_container(container)
|
|
64
|
-
|
|
86
|
+
return unless container
|
|
65
87
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
88
|
+
dump_container_logs(container) if container.running?
|
|
89
|
+
stop_running_container(container)
|
|
90
|
+
container.remove
|
|
91
|
+
end
|
|
69
92
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
end
|
|
93
|
+
def dump_container_logs(container)
|
|
94
|
+
return unless ENV["SHOW_CONTAINER_LOGS"]
|
|
73
95
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
96
|
+
stdout, stderr = container.logs
|
|
97
|
+
reporter.message("Container logs:")
|
|
98
|
+
reporter.message(stderr.to_s) unless stderr.to_s.empty?
|
|
99
|
+
reporter.message(stdout.to_s) unless stdout.to_s.empty?
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def stop_running_container(container)
|
|
103
|
+
return unless container.running?
|
|
104
|
+
|
|
105
|
+
reporter.message("🧹 #{container.image} stopping container...")
|
|
106
|
+
container.stop
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def reporter
|
|
110
|
+
RSpec.configuration.reporter
|
|
78
111
|
end
|
|
79
112
|
|
|
80
113
|
def chromedriver_tests_present?
|