bidi2pdf 0.1.14 → 0.1.16
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/.rubocop.yml +20 -0
- data/CHANGELOG.md +69 -2
- data/README.md +258 -10
- data/docker/Dockerfile +4 -0
- data/docker/Dockerfile.slim +5 -1
- data/lib/bidi2pdf/bidi/browser_tab.rb +45 -4
- data/lib/bidi2pdf/bidi/buffered_web_socket_client.rb +178 -0
- data/lib/bidi2pdf/bidi/client.rb +12 -5
- data/lib/bidi2pdf/bidi/commands/base.rb +2 -0
- data/lib/bidi2pdf/bidi/network_event.rb +12 -3
- data/lib/bidi2pdf/bidi/network_events.rb +9 -1
- data/lib/bidi2pdf/bidi/session.rb +1 -1
- data/lib/bidi2pdf/chromedriver_manager.rb +10 -6
- data/lib/bidi2pdf/cli/json_output.rb +30 -0
- data/lib/bidi2pdf/cli.rb +559 -17
- data/lib/bidi2pdf/diagnose.rb +119 -0
- data/lib/bidi2pdf/error_codes.rb +59 -0
- data/lib/bidi2pdf/exit_codes.rb +44 -0
- data/lib/bidi2pdf/launcher.rb +23 -0
- data/lib/bidi2pdf/manifest.rb +65 -0
- data/lib/bidi2pdf/notifications/json_subscriber.rb +78 -0
- data/lib/bidi2pdf/notifications/logging_subscriber.rb +2 -0
- data/lib/bidi2pdf/pdf_inspection.rb +62 -0
- data/lib/bidi2pdf/recipe/loader.rb +44 -0
- data/lib/bidi2pdf/recipe/runner.rb +229 -0
- data/lib/bidi2pdf/recipe/schema_shape.rb +228 -0
- data/lib/bidi2pdf/recipe/validator.rb +138 -0
- data/lib/bidi2pdf/recipe.rb +83 -0
- data/lib/bidi2pdf/result.rb +67 -0
- data/lib/bidi2pdf/result_collector.rb +150 -0
- data/lib/bidi2pdf/schema.rb +382 -0
- data/lib/bidi2pdf/session_runner.rb +42 -0
- data/lib/bidi2pdf/session_warmer.rb +377 -0
- data/lib/bidi2pdf/test_helpers/testcontainers/chromedriver_test_helper.rb +4 -2
- data/lib/bidi2pdf/version.rb +1 -1
- data/lib/bidi2pdf.rb +114 -9
- data/sig/bidi2pdf/bidi/browser_tab.rbs +17 -0
- data/sig/bidi2pdf/bidi/buffered_web_socket_client.rbs +81 -0
- data/sig/bidi2pdf/bidi/client.rbs +10 -2
- data/sig/bidi2pdf/bidi/network_event.rbs +11 -1
- data/sig/bidi2pdf/bidi/session.rbs +1 -1
- data/sig/bidi2pdf/chromedriver_manager.rbs +4 -0
- data/sig/bidi2pdf/cli/json_output.rbs +19 -0
- data/sig/bidi2pdf/cli.rbs +112 -2
- data/sig/bidi2pdf/diagnose.rbs +30 -0
- data/sig/bidi2pdf/error_codes.rbs +21 -0
- data/sig/bidi2pdf/exit_codes.rbs +21 -0
- data/sig/bidi2pdf/launcher.rbs +9 -0
- data/sig/bidi2pdf/manifest.rbs +38 -0
- data/sig/bidi2pdf/notifications/json_subscriber.rbs +45 -0
- data/sig/bidi2pdf/pdf_inspection.rbs +33 -0
- data/sig/bidi2pdf/recipe/loader.rbs +20 -0
- data/sig/bidi2pdf/recipe/runner.rbs +92 -0
- data/sig/bidi2pdf/recipe/schema_shape.rbs +85 -0
- data/sig/bidi2pdf/recipe/validator.rbs +54 -0
- data/sig/bidi2pdf/recipe.rbs +72 -0
- data/sig/bidi2pdf/result.rbs +71 -0
- data/sig/bidi2pdf/result_collector.rbs +106 -0
- data/sig/bidi2pdf/schema.rbs +45 -0
- data/sig/bidi2pdf/session_runner.rbs +17 -0
- data/sig/bidi2pdf/session_warmer.rbs +221 -0
- data/sig/bidi2pdf/test_helpers/testcontainers/chromedriver_test_helper.rbs +1 -1
- data/sig/bidi2pdf/version.rbs +1 -1
- data/sig/bidi2pdf.rbs +84 -0
- data/tasks/release_credentials_check.rake +97 -0
- metadata +39 -4
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Bidi2pdf
|
|
6
|
+
# Collects the render-focused diagnostics behind `bidi2pdf diagnose`: why does the PDF not look
|
|
7
|
+
# like the page, not what the page's content is. Everything here reuses
|
|
8
|
+
# BrowserTab#execute_script - no DOM dump, no headings/links/forms extraction; console and
|
|
9
|
+
# network data come from the caller's own
|
|
10
|
+
# ResultCollector, the same source `render` uses, not from here.
|
|
11
|
+
#
|
|
12
|
+
# Each script returns (or resolves to) a JSON string, extracted via #dig("result", "value") -
|
|
13
|
+
# BiDi's script.evaluate already awaits a returned Promise (awaitPromise: true, see
|
|
14
|
+
# Commands::ScriptEvaluate), so document.fonts.ready needs no wrap_in_promise: wrapper.
|
|
15
|
+
class Diagnose
|
|
16
|
+
PAGE_SCRIPT = <<~JS
|
|
17
|
+
JSON.stringify({
|
|
18
|
+
title: document.title,
|
|
19
|
+
url: window.location.href,
|
|
20
|
+
lang: document.documentElement.lang || null
|
|
21
|
+
})
|
|
22
|
+
JS
|
|
23
|
+
|
|
24
|
+
FONTS_SCRIPT = <<~JS
|
|
25
|
+
document.fonts.ready.then(function () {
|
|
26
|
+
var loaded = [];
|
|
27
|
+
var failed = [];
|
|
28
|
+
document.fonts.forEach(function (face) {
|
|
29
|
+
var label = face.family + " " + face.weight + " " + face.style;
|
|
30
|
+
if (face.status === "error") { failed.push(label); } else { loaded.push(label); }
|
|
31
|
+
});
|
|
32
|
+
return JSON.stringify({ status: failed.length > 0 ? "partial" : "loaded", loaded: loaded, failed: failed });
|
|
33
|
+
})
|
|
34
|
+
JS
|
|
35
|
+
|
|
36
|
+
PRINT_MEDIA_SCRIPT = <<~JS
|
|
37
|
+
(function () {
|
|
38
|
+
var printSheets = [];
|
|
39
|
+
var pageRules = [];
|
|
40
|
+
var unreadable = [];
|
|
41
|
+
|
|
42
|
+
for (var i = 0; i < document.styleSheets.length; i++) {
|
|
43
|
+
var sheet = document.styleSheets[i];
|
|
44
|
+
try {
|
|
45
|
+
var rules = sheet.cssRules || sheet.rules;
|
|
46
|
+
for (var j = 0; j < rules.length; j++) {
|
|
47
|
+
var rule = rules[j];
|
|
48
|
+
if (rule.media && Array.prototype.includes.call(rule.media, "print")) {
|
|
49
|
+
printSheets.push(sheet.href || "(inline)");
|
|
50
|
+
}
|
|
51
|
+
if (typeof CSSRule !== "undefined" && rule.type === CSSRule.PAGE_RULE) {
|
|
52
|
+
pageRules.push(rule.cssText);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
} catch (e) {
|
|
56
|
+
unreadable.push(sheet.href || "(inline)");
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
var fixedOrSticky = [];
|
|
61
|
+
var breakInsideAvoidCount = 0;
|
|
62
|
+
var elements = document.querySelectorAll("body *");
|
|
63
|
+
for (var k = 0; k < elements.length; k++) {
|
|
64
|
+
var el = elements[k];
|
|
65
|
+
var computed = window.getComputedStyle(el);
|
|
66
|
+
if (computed.position === "fixed" || computed.position === "sticky") {
|
|
67
|
+
var classPart = typeof el.className === "string" && el.className.trim() ? "." + el.className.trim().split(/\\s+/).join(".") : "";
|
|
68
|
+
var selector = el.id ? "#" + el.id : el.tagName.toLowerCase() + classPart;
|
|
69
|
+
fixedOrSticky.push({ selector: selector, position: computed.position });
|
|
70
|
+
}
|
|
71
|
+
if (computed.breakInside === "avoid" || computed.pageBreakInside === "avoid") {
|
|
72
|
+
breakInsideAvoidCount++;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return JSON.stringify({
|
|
77
|
+
stylesheets_with_print_rules: Array.from(new Set(printSheets)),
|
|
78
|
+
page_rules: pageRules,
|
|
79
|
+
break_inside_avoid_count: breakInsideAvoidCount,
|
|
80
|
+
fixed_or_sticky_elements: fixedOrSticky,
|
|
81
|
+
unreadable_stylesheets: Array.from(new Set(unreadable))
|
|
82
|
+
});
|
|
83
|
+
})()
|
|
84
|
+
JS
|
|
85
|
+
|
|
86
|
+
PAGED_JS_SCRIPT = <<~JS
|
|
87
|
+
(function () {
|
|
88
|
+
var detected = !!(window.Paged || window.PagedPolyfill);
|
|
89
|
+
var pages = document.querySelectorAll(".pagedjs_page").length;
|
|
90
|
+
return JSON.stringify({ detected: detected, ready: pages > 0, pages: pages });
|
|
91
|
+
})()
|
|
92
|
+
JS
|
|
93
|
+
|
|
94
|
+
def initialize(tab:)
|
|
95
|
+
@tab = tab
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def call
|
|
99
|
+
{
|
|
100
|
+
page: collect(PAGE_SCRIPT),
|
|
101
|
+
fonts: collect(FONTS_SCRIPT),
|
|
102
|
+
print_media: collect(PRINT_MEDIA_SCRIPT),
|
|
103
|
+
paged_js: collect(PAGED_JS_SCRIPT)
|
|
104
|
+
}
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
def collect(script)
|
|
110
|
+
response = @tab.execute_script(script)
|
|
111
|
+
return nil unless response.is_a?(Hash) && response["type"] == "success"
|
|
112
|
+
|
|
113
|
+
value = response.dig("result", "value")
|
|
114
|
+
value ? JSON.parse(value) : nil
|
|
115
|
+
rescue JSON::ParserError
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bidi2pdf
|
|
4
|
+
# Maps an exception to the stable, machine-readable code used by --json/--json-stream/manifests
|
|
5
|
+
# and recipe results. Derived from the existing Bidi2pdf::Error hierarchy rather than duplicated
|
|
6
|
+
# beside it: a new failure mode gets a new subclass in lib/bidi2pdf.rb and a row here, never a
|
|
7
|
+
# bare string minted ad hoc.
|
|
8
|
+
#
|
|
9
|
+
# MAPPING is ordered most-specific-subclass first - #for walks it in order and returns the first
|
|
10
|
+
# match, so a subclass must appear before any of its ancestors.
|
|
11
|
+
module ErrorCodes
|
|
12
|
+
MAPPING = {
|
|
13
|
+
MissingInputError => "MISSING_INPUT",
|
|
14
|
+
MultipleInputSourcesError => "MULTIPLE_INPUT_SOURCES",
|
|
15
|
+
EmptyInputError => "EMPTY_INPUT",
|
|
16
|
+
InvalidConfigError => "INVALID_CONFIG",
|
|
17
|
+
InvalidPrintOptionError => "INVALID_PRINT_OPTION",
|
|
18
|
+
InvalidRecipeError => "INVALID_RECIPE",
|
|
19
|
+
PdfInspectionUnavailableError => "PDF_INSPECTION_UNAVAILABLE",
|
|
20
|
+
SessionNotStartedError => "BROWSER_LAUNCH_FAILED",
|
|
21
|
+
CmdTimeoutError => "COMMAND_TIMEOUT",
|
|
22
|
+
WebsocketError => "BROWSER_DISCONNECTED",
|
|
23
|
+
NavigationTimeoutError => "NAVIGATION_TIMEOUT",
|
|
24
|
+
NavigationAuthError => "NAVIGATION_AUTH",
|
|
25
|
+
NavigationNotFoundError => "NAVIGATION_NOT_FOUND",
|
|
26
|
+
NavigationDNSError => "DNS_ERROR",
|
|
27
|
+
NavigationError => "NAVIGATION_FAILED",
|
|
28
|
+
SelectorNotFoundError => "SELECTOR_NOT_FOUND",
|
|
29
|
+
PageNotAsExpectedError => "PAGE_NOT_AS_EXPECTED",
|
|
30
|
+
ScriptInjectionError => "SCRIPT_ERROR",
|
|
31
|
+
StyleInjectionError => "STYLE_ERROR",
|
|
32
|
+
PrintError => "PDF_GENERATION_FAILED",
|
|
33
|
+
ScreenshotError => "SCREENSHOT_FAILED",
|
|
34
|
+
OutputWriteError => "OUTPUT_WRITE_FAILED",
|
|
35
|
+
Error => "INTERNAL_ERROR"
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
DEFAULT_CODE = "INTERNAL_ERROR"
|
|
39
|
+
|
|
40
|
+
def self.for(exception)
|
|
41
|
+
_klass, code = MAPPING.find { |klass, _code| exception.is_a?(klass) }
|
|
42
|
+
|
|
43
|
+
code || DEFAULT_CODE
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Builds the {code:, message:, retryable:, hint:, details:} hash used as Result#error and in
|
|
47
|
+
# recipe action/assertion failures. Works for any StandardError, not only a Bidi2pdf::Error -
|
|
48
|
+
# an unexpected exception still gets INTERNAL_ERROR with retryable/hint defaulted safely.
|
|
49
|
+
def self.describe(exception, details: {})
|
|
50
|
+
{
|
|
51
|
+
code: self.for(exception),
|
|
52
|
+
message: exception.message,
|
|
53
|
+
retryable: exception.respond_to?(:retryable?) ? exception.retryable? : false,
|
|
54
|
+
hint: exception.respond_to?(:hint) ? exception.hint : nil,
|
|
55
|
+
details: exception.respond_to?(:details) && !exception.details.empty? ? exception.details : details
|
|
56
|
+
}
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bidi2pdf
|
|
4
|
+
# Maps an ErrorCodes string to the process exit status the CLI reports. Centralized here so
|
|
5
|
+
# render/diagnose/run share one table instead of each re-deciding what a given failure is worth.
|
|
6
|
+
module ExitCodes
|
|
7
|
+
SUCCESS = 0
|
|
8
|
+
CLI_ERROR = 2
|
|
9
|
+
BROWSER_ERROR = 3
|
|
10
|
+
PAGE_NOT_AS_EXPECTED = 4
|
|
11
|
+
OUTPUT_ERROR = 6
|
|
12
|
+
INTERNAL_ERROR = 70
|
|
13
|
+
|
|
14
|
+
CODE_TO_EXIT = {
|
|
15
|
+
"MISSING_INPUT" => CLI_ERROR,
|
|
16
|
+
"MULTIPLE_INPUT_SOURCES" => CLI_ERROR,
|
|
17
|
+
"EMPTY_INPUT" => CLI_ERROR,
|
|
18
|
+
"INVALID_CONFIG" => CLI_ERROR,
|
|
19
|
+
"INVALID_PRINT_OPTION" => CLI_ERROR,
|
|
20
|
+
"INVALID_RECIPE" => CLI_ERROR,
|
|
21
|
+
"PDF_INSPECTION_UNAVAILABLE" => CLI_ERROR,
|
|
22
|
+
"BROWSER_LAUNCH_FAILED" => BROWSER_ERROR,
|
|
23
|
+
"BROWSER_DISCONNECTED" => BROWSER_ERROR,
|
|
24
|
+
"COMMAND_TIMEOUT" => BROWSER_ERROR,
|
|
25
|
+
"NAVIGATION_FAILED" => BROWSER_ERROR,
|
|
26
|
+
"NAVIGATION_TIMEOUT" => BROWSER_ERROR,
|
|
27
|
+
"NAVIGATION_AUTH" => BROWSER_ERROR,
|
|
28
|
+
"NAVIGATION_NOT_FOUND" => BROWSER_ERROR,
|
|
29
|
+
"DNS_ERROR" => BROWSER_ERROR,
|
|
30
|
+
"SELECTOR_NOT_FOUND" => PAGE_NOT_AS_EXPECTED,
|
|
31
|
+
"PAGE_NOT_AS_EXPECTED" => PAGE_NOT_AS_EXPECTED,
|
|
32
|
+
"SCRIPT_ERROR" => PAGE_NOT_AS_EXPECTED,
|
|
33
|
+
"STYLE_ERROR" => PAGE_NOT_AS_EXPECTED,
|
|
34
|
+
"PDF_GENERATION_FAILED" => OUTPUT_ERROR,
|
|
35
|
+
"SCREENSHOT_FAILED" => OUTPUT_ERROR,
|
|
36
|
+
"OUTPUT_WRITE_FAILED" => OUTPUT_ERROR,
|
|
37
|
+
"INTERNAL_ERROR" => INTERNAL_ERROR
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
def self.for(code)
|
|
41
|
+
CODE_TO_EXIT.fetch(code, INTERNAL_ERROR)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
data/lib/bidi2pdf/launcher.rb
CHANGED
|
@@ -77,7 +77,30 @@ module Bidi2pdf
|
|
|
77
77
|
runner.run
|
|
78
78
|
end
|
|
79
79
|
|
|
80
|
+
# Like #launch, but navigates only (see SessionRunner#run_diagnose) and leaves the tab open -
|
|
81
|
+
# used by `bidi2pdf diagnose`, which has no PDF to produce. #stop closes it, same as it stops
|
|
82
|
+
# everything else #launch/#diagnose started.
|
|
83
|
+
#
|
|
84
|
+
# @return [Bidi2pdf::Bidi::BrowserTab] the navigated tab.
|
|
85
|
+
def diagnose
|
|
86
|
+
@diagnose_runner = SessionRunner.new(
|
|
87
|
+
session: session,
|
|
88
|
+
url: @url,
|
|
89
|
+
inputfile: @inputfile,
|
|
90
|
+
output: @output,
|
|
91
|
+
cookies: @cookies,
|
|
92
|
+
headers: @headers,
|
|
93
|
+
auth: @auth,
|
|
94
|
+
wait_window_loaded: @wait_window_loaded,
|
|
95
|
+
wait_network_idle: @wait_network_idle,
|
|
96
|
+
print_options: @print_options,
|
|
97
|
+
network_log_format: @network_log_format
|
|
98
|
+
)
|
|
99
|
+
@diagnose_runner.run_diagnose
|
|
100
|
+
end
|
|
101
|
+
|
|
80
102
|
def stop
|
|
103
|
+
@diagnose_runner&.close_all
|
|
81
104
|
@manager&.stop
|
|
82
105
|
@custom_session&.close
|
|
83
106
|
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Bidi2pdf
|
|
6
|
+
# Builds the render manifest (--manifest FILE) from a completed Bidi2pdf::Result plus the
|
|
7
|
+
# input/browser/header context around it. Redacts secret-
|
|
8
|
+
# bearing headers and never records cookie values at all.
|
|
9
|
+
class Manifest
|
|
10
|
+
SCHEMA_VERSION = 1
|
|
11
|
+
|
|
12
|
+
REDACT_HEADER_NAMES = %w[authorization proxy-authorization cookie set-cookie x-api-key api-key].freeze
|
|
13
|
+
SENSITIVE_SUBSTRINGS = %w[token secret password authorization api-key apikey].freeze
|
|
14
|
+
|
|
15
|
+
def initialize(result:, input:, headers: {}, navigation_duration_ms: nil, browser: {})
|
|
16
|
+
@result = result
|
|
17
|
+
@input = input
|
|
18
|
+
@headers = headers
|
|
19
|
+
@navigation_duration_ms = navigation_duration_ms
|
|
20
|
+
@browser = browser
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.redact(headers)
|
|
24
|
+
headers.to_h { |key, value| [key, sensitive?(key) ? "[REDACTED]" : value] }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.sensitive?(key)
|
|
28
|
+
normalized = key.to_s.downcase
|
|
29
|
+
|
|
30
|
+
REDACT_HEADER_NAMES.include?(normalized) || SENSITIVE_SUBSTRINGS.any? { |substring| normalized.include?(substring) }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def to_h
|
|
34
|
+
base = {
|
|
35
|
+
schema_version: SCHEMA_VERSION,
|
|
36
|
+
bidi2pdf_version: Bidi2pdf::VERSION,
|
|
37
|
+
created_at: Time.now.utc.iso8601,
|
|
38
|
+
input: @input,
|
|
39
|
+
output: output_section,
|
|
40
|
+
browser: @browser,
|
|
41
|
+
navigation: navigation_section,
|
|
42
|
+
render: { duration_ms: @result.duration_ms },
|
|
43
|
+
console: @result.console,
|
|
44
|
+
network_failures: @result.network_failures,
|
|
45
|
+
warnings: @result.warnings
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
@headers.empty? ? base : base.merge(headers: self.class.redact(@headers))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def to_json(*)
|
|
52
|
+
to_h.to_json(*)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def output_section
|
|
58
|
+
{ path: @result.output, pages: @result.pages, bytes: @result.bytes, sha256: @result.sha256 }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def navigation_section
|
|
62
|
+
(@result.navigation || {}).merge(duration_ms: @navigation_duration_ms)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Bidi2pdf
|
|
6
|
+
module Notifications
|
|
7
|
+
# Emits one JSON object per line to an IO (stderr for --json-stream, docs/specs/llm-friendly-
|
|
8
|
+
# spec.md section 7) as a render progresses, by subscribing to the same Bidi2pdf::Notifications
|
|
9
|
+
# events Bidi2pdf::ResultCollector and LoggingSubscriber already use - no new instrumentation
|
|
10
|
+
# points needed. #emit_result writes the final "result" event once the caller has a Result.
|
|
11
|
+
class JsonSubscriber
|
|
12
|
+
SCHEMA_VERSION = 1
|
|
13
|
+
|
|
14
|
+
def initialize(io: $stderr)
|
|
15
|
+
@io = io
|
|
16
|
+
@start = now_ms
|
|
17
|
+
subscribe
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def emit_result(result)
|
|
21
|
+
write(event: "result", result: result.to_h)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def unsubscribe
|
|
25
|
+
Array(@registered).each { |pattern, block| Bidi2pdf.notification_service.unsubscribe(pattern, block) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def now_ms = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
|
|
31
|
+
|
|
32
|
+
def elapsed_ms = (now_ms - @start).round
|
|
33
|
+
|
|
34
|
+
def subscribe
|
|
35
|
+
handlers = {
|
|
36
|
+
"navigate_to.bidi2pdf" => method(:on_navigate),
|
|
37
|
+
"render_html_content.bidi2pdf" => method(:on_navigate),
|
|
38
|
+
"page_loaded.bidi2pdf" => method(:on_page_loaded),
|
|
39
|
+
"network_idle.bidi2pdf" => method(:on_network_idle),
|
|
40
|
+
"browser_console_log_received.bidi2pdf" => method(:on_console),
|
|
41
|
+
"print.bidi2pdf" => method(:on_print),
|
|
42
|
+
"screenshot.bidi2pdf" => method(:on_screenshot)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
@registered = handlers.map { |pattern, handler| [pattern, Bidi2pdf.notification_service.subscribe(pattern, &handler)] }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def on_navigate(event)
|
|
49
|
+
write(event: "navigate", url: event.payload[:url])
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def on_page_loaded(_event)
|
|
53
|
+
write(event: "page_loaded")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def on_network_idle(_event)
|
|
57
|
+
write(event: "network_idle")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def on_console(event)
|
|
61
|
+
payload = event.payload
|
|
62
|
+
write(event: "console", level: payload[:level], text: payload[:text])
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def on_print(_event)
|
|
66
|
+
write(event: "print")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def on_screenshot(_event)
|
|
70
|
+
write(event: "screenshot")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def write(fields)
|
|
74
|
+
@io.puts({ schema_version: SCHEMA_VERSION, t_ms: elapsed_ms }.merge(fields).to_json)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
|
|
5
|
+
module Bidi2pdf
|
|
6
|
+
# Lazy, optional wrapper around the pdf-reader gem. pdf-reader is deliberately NOT a bidi2pdf
|
|
7
|
+
# runtime dependency - render works fine without it,
|
|
8
|
+
# just with `pages` left nil and a warning, and a recipe assertion that needs it fails
|
|
9
|
+
# INVALID_RECIPE at validation time rather than crashing mid-render. The official Docker images
|
|
10
|
+
# (docker/Dockerfile, docker/Dockerfile.slim) install it, so a render inside one of them - the
|
|
11
|
+
# environment an agent or CI job actually uses - always has page counts and PDF assertions
|
|
12
|
+
# available without anyone opting in by hand.
|
|
13
|
+
module PdfInspection
|
|
14
|
+
class << self
|
|
15
|
+
def available?
|
|
16
|
+
return @available if defined?(@available)
|
|
17
|
+
|
|
18
|
+
@available = load_pdf_reader
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @param bytes [String, nil] raw PDF bytes.
|
|
22
|
+
# @return [Integer, nil] page count, or nil when pdf-reader is unavailable, bytes is nil,
|
|
23
|
+
# or the PDF is malformed.
|
|
24
|
+
def page_count(bytes)
|
|
25
|
+
return nil unless bytes && available?
|
|
26
|
+
|
|
27
|
+
reader_for(bytes).page_count
|
|
28
|
+
rescue PDF::Reader::MalformedPDFError
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @param bytes [String, nil] raw PDF bytes.
|
|
33
|
+
# @return [String, nil] extracted text across all pages, joined with newlines.
|
|
34
|
+
def text(bytes)
|
|
35
|
+
return nil unless bytes && available?
|
|
36
|
+
|
|
37
|
+
reader_for(bytes).pages.map(&:text).join("\n")
|
|
38
|
+
rescue PDF::Reader::MalformedPDFError
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Clears the memoized availability check - test-only, so a spec can exercise both branches
|
|
43
|
+
# of #available? regardless of load order elsewhere in the process.
|
|
44
|
+
def reset_for_testing!
|
|
45
|
+
remove_instance_variable(:@available) if defined?(@available)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def load_pdf_reader
|
|
51
|
+
require "pdf-reader"
|
|
52
|
+
true
|
|
53
|
+
rescue LoadError
|
|
54
|
+
false
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def reader_for(bytes)
|
|
58
|
+
PDF::Reader.new(StringIO.new(bytes))
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Bidi2pdf
|
|
7
|
+
class Recipe
|
|
8
|
+
# Reads a recipe file (YAML, or JSON when the extension is .json) into a plain Hash. Never
|
|
9
|
+
# launches a browser - a malformed file fails right here.
|
|
10
|
+
#
|
|
11
|
+
# Security: YAML is parsed with Psych.safe_load and no extra options. Its defaults already
|
|
12
|
+
# refuse both custom type tags (no arbitrary Ruby object instantiation) and aliases - the
|
|
13
|
+
# latter matters concretely: a small file using nested YAML anchors/aliases ("billion laughs")
|
|
14
|
+
# can expand to millions of elements in memory; confirmed live, a 273-byte such file produced
|
|
15
|
+
# 10 million array elements in under 2ms. Neither aliases nor a Date/Time value are used by any
|
|
16
|
+
# field a recipe defines, so neither is turned back on "just in case" - only what the format
|
|
17
|
+
# actually needs is permitted. MAX_BYTES bounds the file itself before it is even parsed.
|
|
18
|
+
module Loader
|
|
19
|
+
MAX_BYTES = 1_048_576 # 1 MiB - generous for a recipe file, small enough to bound a huge one.
|
|
20
|
+
|
|
21
|
+
# rubocop:disable-next Metrics/AbcSize
|
|
22
|
+
def self.load(path)
|
|
23
|
+
raise Bidi2pdf::InvalidRecipeError.new("Recipe file not found: #{path}", details: { path: path }) unless File.exist?(path)
|
|
24
|
+
|
|
25
|
+
raise Bidi2pdf::InvalidRecipeError.new("Recipe file '#{path}' is too large (max #{MAX_BYTES} bytes)", details: { path: path }) if File.size(path) > MAX_BYTES
|
|
26
|
+
|
|
27
|
+
content = File.read(path)
|
|
28
|
+
parsed = path.end_with?(".json") ? JSON.parse(content) : YAML.safe_load(content)
|
|
29
|
+
|
|
30
|
+
raise Bidi2pdf::InvalidRecipeError.new("Recipe must be a mapping at the top level", details: { path: "." }) unless parsed.is_a?(Hash)
|
|
31
|
+
|
|
32
|
+
parsed
|
|
33
|
+
rescue Psych::Exception, JSON::ParserError => e
|
|
34
|
+
# Psych::Exception covers SyntaxError, AliasesNotEnabled and DisallowedClass alike, so a
|
|
35
|
+
# recipe attempting to use any of the features safe_load refuses fails the same clean way
|
|
36
|
+
# a syntax error would, not with an unhandled exception. The message is truncated the same
|
|
37
|
+
# way Bidi2pdf.truncate_for_log bounds any other value that can hold arbitrary input -
|
|
38
|
+
# a recipe may carry credentials (basic auth, headers), and a parser error's own message
|
|
39
|
+
# can otherwise quote a slice of the offending content straight back.
|
|
40
|
+
raise Bidi2pdf::InvalidRecipeError.new("Could not parse recipe '#{path}': #{Bidi2pdf.truncate_for_log(e.message)}", details: { path: path })
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|