weasy_pdf 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +41 -0
- data/LICENSE +21 -0
- data/README.md +281 -0
- data/lib/weasy_pdf/command_builder.rb +23 -0
- data/lib/weasy_pdf/configuration.rb +62 -0
- data/lib/weasy_pdf/middleware.rb +69 -0
- data/lib/weasy_pdf/pdf_helper.rb +91 -0
- data/lib/weasy_pdf/railtie.rb +23 -0
- data/lib/weasy_pdf/renderer.rb +217 -0
- data/lib/weasy_pdf/version.rb +5 -0
- data/lib/weasy_pdf/view_helpers/assets.rb +113 -0
- data/lib/weasy_pdf/view_helpers/vite_assets.rb +110 -0
- data/lib/weasy_pdf.rb +78 -0
- data/sig/configuration.rbs +16 -0
- data/sig/renderer.rbs +10 -0
- data/sig/weasy_pdf.rbs +40 -0
- metadata +151 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "timeout"
|
|
7
|
+
require "net/http"
|
|
8
|
+
|
|
9
|
+
module WeasyPDF
|
|
10
|
+
class Renderer
|
|
11
|
+
# WeasyPrint-relevant options that the renderer reads.
|
|
12
|
+
# Any other keys in the options hash are silently ignored —
|
|
13
|
+
# this allows wkhtmltopdf options to be passed without error during migration.
|
|
14
|
+
WEASYPRINT_OPTIONS = %i[
|
|
15
|
+
page_size page_height page_width orientation
|
|
16
|
+
margin margin_top margin_bottom margin_left margin_right
|
|
17
|
+
encoding zoom media_type base_url stylesheets
|
|
18
|
+
header footer save_to_file save_only
|
|
19
|
+
].freeze
|
|
20
|
+
|
|
21
|
+
def initialize(options = {})
|
|
22
|
+
merged = WeasyPDF.configuration.default_options.merge(options)
|
|
23
|
+
# :wkhtmltopdf was WickedPdf's per-instance binary override — accept it as exe_path
|
|
24
|
+
# so apps that pass it during migration don't need to change that option key.
|
|
25
|
+
@binary = merged.delete(:wkhtmltopdf) || WeasyPDF.configuration.exe_path
|
|
26
|
+
@timeout = merged.delete(:timeout) || WeasyPDF.configuration.timeout
|
|
27
|
+
@options = merged.slice(*WEASYPRINT_OPTIONS)
|
|
28
|
+
validate_binary!
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def pdf_from_string(html_string, options = {})
|
|
32
|
+
opts = @options.merge(options)
|
|
33
|
+
html = inject_page_css(html_string, opts)
|
|
34
|
+
|
|
35
|
+
FileUtils.mkdir_p(WeasyPDF.configuration.temp_path)
|
|
36
|
+
input = write_temp_html(html)
|
|
37
|
+
output = temp_pdf_path
|
|
38
|
+
|
|
39
|
+
begin
|
|
40
|
+
run!(input, output, opts)
|
|
41
|
+
result = File.binread(output)
|
|
42
|
+
|
|
43
|
+
if opts[:save_to_file]
|
|
44
|
+
FileUtils.mkdir_p(File.dirname(opts[:save_to_file].to_s))
|
|
45
|
+
File.binwrite(opts[:save_to_file].to_s, result)
|
|
46
|
+
return nil if opts[:save_only]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
result
|
|
50
|
+
ensure
|
|
51
|
+
FileUtils.rm_f([input, output])
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def pdf_from_html_file(filepath, options = {})
|
|
56
|
+
raise ArgumentError, "File not found: #{filepath}" unless File.exist?(filepath)
|
|
57
|
+
|
|
58
|
+
pdf_from_string(File.read(filepath.to_s, encoding: "utf-8"), options)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def pdf_from_url(url, options = {})
|
|
62
|
+
response = Net::HTTP.get_response(URI.parse(url))
|
|
63
|
+
raise WeasyPDF::Error, "HTTP #{response.code} fetching #{url}" \
|
|
64
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
65
|
+
|
|
66
|
+
pdf_from_string(response.body, options)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
# ── Execution ─────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
def run!(input_path, output_path, options = {})
|
|
74
|
+
cmd = build_command(input_path, output_path, options)
|
|
75
|
+
Timeout.timeout(@timeout) do
|
|
76
|
+
_out, err, status = Open3.capture3(*cmd)
|
|
77
|
+
unless status.success?
|
|
78
|
+
raise GenerationError,
|
|
79
|
+
"WeasyPrint failed (exit #{status.exitstatus}): #{err.strip}"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
rescue ::Timeout::Error
|
|
83
|
+
raise WeasyPDF::TimeoutError, "WeasyPrint timed out after #{@timeout}s"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def build_command(input_path, output_path, options = {})
|
|
87
|
+
# Resolve base_url here so CommandBuilder stays free of global state.
|
|
88
|
+
resolved = options[:base_url] || WeasyPDF.configuration.base_url
|
|
89
|
+
opts = resolved ? options.merge(base_url: resolved) : options
|
|
90
|
+
CommandBuilder.new(@binary, opts).build(input_path, output_path)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# ── CSS @page injection ───────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
def inject_page_css(html, options = {})
|
|
96
|
+
css = build_page_css(options)
|
|
97
|
+
tag = "<style>\n#{css}\n</style>"
|
|
98
|
+
if html.match?(/<head[^>]*>/i)
|
|
99
|
+
html.sub(/<head[^>]*>/i) { |m| "#{m}\n#{tag}" }
|
|
100
|
+
else
|
|
101
|
+
"#{tag}\n#{html}"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def build_page_css(options = {})
|
|
106
|
+
lines = ["@page { size: #{page_size_str(options)}; margin: #{build_margin_string(options)}; }"]
|
|
107
|
+
|
|
108
|
+
# Header/footer via CSS @page margin boxes.
|
|
109
|
+
# :html/:template options are rejected by PdfHelper before reaching here.
|
|
110
|
+
add_header_footer_css(lines, options[:header], "@top")
|
|
111
|
+
add_header_footer_css(lines, options[:footer], "@bottom")
|
|
112
|
+
|
|
113
|
+
lines.join("\n")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def page_size_str(options)
|
|
117
|
+
case options
|
|
118
|
+
in {page_height: (Integer | String) => h, page_width: (Integer | String) => w}
|
|
119
|
+
"#{w}mm #{h}mm"
|
|
120
|
+
in {orientation: /\Alandscape\z/i}
|
|
121
|
+
"#{options[:page_size] || "A4"} landscape"
|
|
122
|
+
else
|
|
123
|
+
options[:page_size] || "A4"
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def build_margin_string(options = {})
|
|
128
|
+
m = options[:margin] || {}
|
|
129
|
+
top = m[:top] || options[:margin_top] || "10mm"
|
|
130
|
+
bottom = m[:bottom] || options[:margin_bottom] || "10mm"
|
|
131
|
+
left = m[:left] || options[:margin_left] || "10mm"
|
|
132
|
+
right = m[:right] || options[:margin_right] || "10mm"
|
|
133
|
+
|
|
134
|
+
top, right_m, bottom, left = [top, right, bottom, left].map { |v| v.is_a?(Numeric) ? "#{v}mm" : v.to_s }
|
|
135
|
+
"#{top} #{right_m} #{bottom} #{left}"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def add_header_footer_css(lines, hf, position)
|
|
139
|
+
return unless hf.is_a?(Hash)
|
|
140
|
+
|
|
141
|
+
# Only simple text content here — :html/:template are rejected before reaching Renderer.
|
|
142
|
+
{
|
|
143
|
+
left: "#{position}-left",
|
|
144
|
+
center: "#{position}-center",
|
|
145
|
+
right: "#{position}-right"
|
|
146
|
+
}.each do |key, box|
|
|
147
|
+
next unless hf[key].is_a?(String)
|
|
148
|
+
|
|
149
|
+
content_val = wkhtml_to_css_content(hf[key])
|
|
150
|
+
font_style = css_font_style(hf)
|
|
151
|
+
lines << "@page { #{box} { content: #{content_val}; #{font_style} } }"
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def wkhtml_to_css_content(str)
|
|
156
|
+
# Unsupported tokens ([section], [title], etc.) are silently removed.
|
|
157
|
+
# NUL byte (\x00) is the split sentinel: it can't appear in CSS text,
|
|
158
|
+
# so it cleanly separates literal strings from counter names after split.
|
|
159
|
+
parts = str.gsub("[page]", "\x00page\x00")
|
|
160
|
+
.gsub("[topage]", "\x00pages\x00")
|
|
161
|
+
.gsub(/\[[^\]]+\]/, "")
|
|
162
|
+
.split("\x00")
|
|
163
|
+
|
|
164
|
+
segments = parts.map.with_index do |part, i|
|
|
165
|
+
if i.odd?
|
|
166
|
+
"counter(#{part})"
|
|
167
|
+
else
|
|
168
|
+
part.empty? ? nil : "\"#{esc(part)}\""
|
|
169
|
+
end
|
|
170
|
+
end.compact
|
|
171
|
+
|
|
172
|
+
segments.empty? ? '""' : segments.join(" ")
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def css_font_style(hf)
|
|
176
|
+
parts = []
|
|
177
|
+
parts << "font-family: #{hf[:font_name]};" if hf[:font_name]
|
|
178
|
+
parts << "font-size: #{hf[:font_size]}pt;" if hf[:font_size]
|
|
179
|
+
parts.join(" ")
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Block form: gsub's string-replacement syntax interprets \\ as an escape,
|
|
183
|
+
# so gsub("\\", "\\\\") is silently a no-op. Block form takes the replacement verbatim.
|
|
184
|
+
def esc(str)
|
|
185
|
+
str.to_s.gsub("\\") { "\\\\" }.gsub('"') { '\\"' }
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# ── Temp files ────────────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
def write_temp_html(html)
|
|
191
|
+
path = WeasyPDF.configuration.temp_path.join("weasy_#{SecureRandom.hex(8)}.html").to_s
|
|
192
|
+
File.write(path, html, encoding: "utf-8")
|
|
193
|
+
path
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def temp_pdf_path
|
|
197
|
+
WeasyPDF.configuration.temp_path.join("weasy_#{SecureRandom.hex(8)}.pdf").to_s
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# ── Validation ────────────────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
def validate_binary!
|
|
203
|
+
bin = @binary.to_s
|
|
204
|
+
return if File.executable?(bin)
|
|
205
|
+
# bare name (no '/') — File.executable? only checks CWD, so search PATH explicitly
|
|
206
|
+
return if !bin.include?("/") &&
|
|
207
|
+
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? { |d| File.executable?(File.join(d, bin)) }
|
|
208
|
+
|
|
209
|
+
raise BinaryNotFoundError,
|
|
210
|
+
"WeasyPrint binary not found at '#{@binary}'.\n " \
|
|
211
|
+
"Install with: apt install weasyprint (Debian/Ubuntu)\n " \
|
|
212
|
+
"brew install weasyprint (macOS)\n " \
|
|
213
|
+
"pip install weasyprint (latest version)\n " \
|
|
214
|
+
"Or configure: WeasyPDF.configure { |c| c.exe_path = '/path/to/weasyprint' }"
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
|
|
5
|
+
module WeasyPDF
|
|
6
|
+
module ViewHelpers
|
|
7
|
+
# Base asset helpers. Resolves assets from public/ directories on disk.
|
|
8
|
+
# When vite_ruby is present, ViteAssets is included on top and overrides
|
|
9
|
+
# resolution to use the Vite manifest instead.
|
|
10
|
+
module Assets
|
|
11
|
+
ASSET_URL_REGEX = /url\(['"]?([^'")\s]+?)['"]?\)/
|
|
12
|
+
|
|
13
|
+
# ── Stylesheets ────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
def weasy_pdf_stylesheet_link_tag(*sources)
|
|
16
|
+
sources.map do |source|
|
|
17
|
+
source = add_extension(source, "css")
|
|
18
|
+
path = find_asset_path(source) { raise WeasyPDF::MissingLocalAsset, source }
|
|
19
|
+
css = File.read(path, encoding: "utf-8")
|
|
20
|
+
css = rewrite_asset_urls(css)
|
|
21
|
+
"<style type='text/css'>#{css}</style>"
|
|
22
|
+
end.join("\n").html_safe
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# ── Images ─────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
def weasy_pdf_image_tag(source, options = {})
|
|
28
|
+
image_tag(weasy_pdf_asset_path(source), options)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# ── Asset paths ────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
def weasy_pdf_asset_path(asset)
|
|
34
|
+
return asset if asset.to_s.start_with?("http://", "https://", "data:", "//")
|
|
35
|
+
|
|
36
|
+
resolve_asset_path(asset)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def weasy_pdf_asset_base64(path)
|
|
40
|
+
disk = find_asset_path(path) { raise WeasyPDF::MissingLocalAsset, path }
|
|
41
|
+
base64 = Base64.strict_encode64(File.binread(disk))
|
|
42
|
+
"data:#{mime_for(path)};base64,#{base64}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def weasy_pdf_url_base64(url)
|
|
46
|
+
require "net/http"
|
|
47
|
+
response = Net::HTTP.get_response(URI.parse(url))
|
|
48
|
+
raise WeasyPDF::MissingRemoteAsset.new(url, response) unless response.is_a?(Net::HTTPSuccess)
|
|
49
|
+
|
|
50
|
+
base64 = Base64.strict_encode64(response.body.b)
|
|
51
|
+
"data:#{response.content_type};base64,#{base64}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# ── Resolution ────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
def find_asset_path(source)
|
|
59
|
+
strip = source.sub(%r{\A/(?:vite/assets/|assets/)}, "")
|
|
60
|
+
raw = source.sub(%r{\A/}, "")
|
|
61
|
+
|
|
62
|
+
[
|
|
63
|
+
Rails.root.join("public", "vite", "assets", strip),
|
|
64
|
+
Rails.root.join("public", "assets", raw),
|
|
65
|
+
Rails.root.join("public", raw)
|
|
66
|
+
].each do |path|
|
|
67
|
+
return path.to_s if File.exist?(path.to_s)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
block_given? ? yield : nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def resolve_asset_path(asset)
|
|
74
|
+
path = find_asset_path(asset)
|
|
75
|
+
return "file://#{path}" if path
|
|
76
|
+
|
|
77
|
+
if WeasyPDF.configuration.base_url
|
|
78
|
+
sep = asset.start_with?("/") ? "" : "/"
|
|
79
|
+
return "#{WeasyPDF.configuration.base_url}#{sep}#{asset}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
Rails.logger.warn("[WeasyPDF] Could not resolve '#{asset}' to an absolute path.") if defined?(Rails)
|
|
83
|
+
asset
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# CSS inlined from a temp file in /tmp has no base path, so relative url()
|
|
87
|
+
# references would resolve against /tmp rather than the Rails public/ tree.
|
|
88
|
+
def rewrite_asset_urls(css)
|
|
89
|
+
css.gsub(ASSET_URL_REGEX) do
|
|
90
|
+
url = Regexp.last_match[1]
|
|
91
|
+
url.start_with?("data:") ? "url(#{url})" : "url(#{weasy_pdf_asset_path(url)})"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def mime_for(source)
|
|
96
|
+
case File.extname(source.to_s).downcase
|
|
97
|
+
when ".png" then "image/png"
|
|
98
|
+
when ".jpg", ".jpeg" then "image/jpeg"
|
|
99
|
+
when ".gif" then "image/gif"
|
|
100
|
+
when ".svg" then "image/svg+xml"
|
|
101
|
+
when ".webp" then "image/webp"
|
|
102
|
+
when ".avif" then "image/avif"
|
|
103
|
+
when ".css" then "text/css"
|
|
104
|
+
else "application/octet-stream"
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def add_extension(source, ext)
|
|
109
|
+
source.end_with?(".#{ext}") ? source : "#{source}.#{ext}"
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
|
|
5
|
+
module WeasyPDF
|
|
6
|
+
module ViewHelpers
|
|
7
|
+
# Included on top of Assets when vite_ruby is available.
|
|
8
|
+
# Overrides asset resolution to use ViteRuby's manifest.
|
|
9
|
+
module ViteAssets
|
|
10
|
+
def weasy_pdf_stylesheet_link_tag(*sources)
|
|
11
|
+
sources.map { |source| resolve_vite_stylesheet(source) }.join("\n").html_safe
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# weasy_pdf_image_tag is inherited from Assets — it calls weasy_pdf_asset_path,
|
|
15
|
+
# which IS overridden here, so override-by-dispatch is enough. No re-definition needed.
|
|
16
|
+
|
|
17
|
+
def weasy_pdf_asset_path(asset)
|
|
18
|
+
return asset if asset.to_s.start_with?("http://", "https://", "data:", "//")
|
|
19
|
+
|
|
20
|
+
url = vite_asset_url(asset)
|
|
21
|
+
|
|
22
|
+
if cdn_url?(url)
|
|
23
|
+
url
|
|
24
|
+
elsif vite_dev_server_running?
|
|
25
|
+
content = fetch_bytes(url)
|
|
26
|
+
"data:#{mime_for(asset)};base64,#{Base64.strict_encode64(content)}"
|
|
27
|
+
else
|
|
28
|
+
disk = disk_path_from_url(url)
|
|
29
|
+
(disk && File.exist?(disk.to_s)) ? "file://#{disk}" : url
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
# Strategy progression: CDN URL → dev server (HTTP) → compiled file on disk → fallback link.
|
|
36
|
+
# Each branch returns the same shape (a tag string), so the caller can treat them uniformly.
|
|
37
|
+
def resolve_vite_stylesheet(source)
|
|
38
|
+
url = vite_asset_url(source)
|
|
39
|
+
return stylesheet_link(url) if cdn_url?(url)
|
|
40
|
+
return inlined_stylesheet(source, fetch_text(url)) if vite_dev_server_running?
|
|
41
|
+
|
|
42
|
+
disk = disk_path_from_url(url)
|
|
43
|
+
if disk && File.exist?(disk.to_s)
|
|
44
|
+
inlined_stylesheet(source, File.read(disk.to_s, encoding: "utf-8"))
|
|
45
|
+
else
|
|
46
|
+
warn_missing_vite_asset(source, url) if WeasyPDF.configuration.base_url.nil?
|
|
47
|
+
stylesheet_link(url)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def stylesheet_link(url)
|
|
52
|
+
"<link rel='stylesheet' type='text/css' href='#{url}' />"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def inlined_stylesheet(source, css)
|
|
56
|
+
"<style type='text/css'>/* #{source} */\n#{css}</style>"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def warn_missing_vite_asset(source, url)
|
|
60
|
+
Rails.logger.warn(
|
|
61
|
+
"[WeasyPDF] Asset '#{source}' -> '#{url}' not found on disk. " \
|
|
62
|
+
"Run bin/vite build or set WeasyPDF.configure { |c| c.base_url = ... }"
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def vite_asset_url(source)
|
|
67
|
+
ViteRuby.instance.manifest.path_for(source)
|
|
68
|
+
rescue
|
|
69
|
+
respond_to?(:asset_path, true) ? asset_path(source) : "/#{source}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def vite_dev_server_running?
|
|
73
|
+
ViteRuby.instance.dev_server_running?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def cdn_url?(url)
|
|
77
|
+
# Localhost URLs look like external URLs but are actually the Vite dev server.
|
|
78
|
+
url.to_s.match?(%r{\Ahttps?://}) &&
|
|
79
|
+
!url.to_s.match?(/localhost|127\.0\.0\.1/)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def disk_path_from_url(url)
|
|
83
|
+
return nil unless url.to_s.start_with?("/")
|
|
84
|
+
|
|
85
|
+
Rails.root.join("public", url.sub(%r{\A/}, ""))
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def fetch_text(url)
|
|
89
|
+
fetch_bytes(url).encode("utf-8", "binary", invalid: :replace, undef: :replace)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def fetch_bytes(url)
|
|
93
|
+
require "net/http"
|
|
94
|
+
uri = URI.parse(url)
|
|
95
|
+
response = Net::HTTP.get_response(uri)
|
|
96
|
+
raise WeasyPDF::MissingRemoteAsset.new(url, response) \
|
|
97
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
98
|
+
|
|
99
|
+
response.body.b
|
|
100
|
+
rescue WeasyPDF::MissingRemoteAsset
|
|
101
|
+
raise
|
|
102
|
+
rescue => e
|
|
103
|
+
# Network errors during asset resolution shouldn't abort PDF generation;
|
|
104
|
+
# the caller logs and degrades to a <link> tag or omits the asset.
|
|
105
|
+
Rails.logger.warn("[WeasyPDF] Error fetching #{url}: #{e.message}")
|
|
106
|
+
"".b
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
data/lib/weasy_pdf.rb
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
require "weasy_pdf/version"
|
|
6
|
+
require "weasy_pdf/configuration"
|
|
7
|
+
require "weasy_pdf/command_builder"
|
|
8
|
+
require "weasy_pdf/renderer"
|
|
9
|
+
require "weasy_pdf/pdf_helper"
|
|
10
|
+
require "weasy_pdf/middleware"
|
|
11
|
+
require "weasy_pdf/view_helpers/assets"
|
|
12
|
+
require "weasy_pdf/view_helpers/vite_assets"
|
|
13
|
+
|
|
14
|
+
module WeasyPDF
|
|
15
|
+
class Error < StandardError; end
|
|
16
|
+
class BinaryNotFoundError < Error; end
|
|
17
|
+
class GenerationError < Error; end
|
|
18
|
+
class TimeoutError < Error; end
|
|
19
|
+
class MissingAsset < Error; end
|
|
20
|
+
|
|
21
|
+
class MissingLocalAsset < MissingAsset
|
|
22
|
+
attr_reader :path
|
|
23
|
+
|
|
24
|
+
def initialize(path)
|
|
25
|
+
@path = path
|
|
26
|
+
super("Asset not found: '#{path}'")
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
class MissingRemoteAsset < MissingAsset
|
|
31
|
+
attr_reader :url, :response
|
|
32
|
+
|
|
33
|
+
def initialize(url, response)
|
|
34
|
+
@url = url
|
|
35
|
+
@response = response
|
|
36
|
+
super("Could not fetch '#{url}': #{response.code} #{response.message}")
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
class << self
|
|
41
|
+
def configure
|
|
42
|
+
yield configuration
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def configuration
|
|
46
|
+
@configuration ||= Configuration.new
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def config
|
|
50
|
+
configuration
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def config=(hash)
|
|
54
|
+
hash.each { |k, v| configuration[k] = v }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Some WickedPdf initializers call clear_config between requests — alias reset! for drop-in compat.
|
|
58
|
+
def clear_config
|
|
59
|
+
reset!
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def reset!
|
|
63
|
+
@configuration = Configuration.new
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# No-op for API compat — WeasyPDF has no deprecation warnings to silence.
|
|
67
|
+
def silence_deprecations
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Drop-in equivalent of WickedPdf.new — returns a Renderer instance.
|
|
71
|
+
# WeasyPDF.new(options).pdf_from_string(html) ≡ WickedPdf.new.pdf_from_string(html).
|
|
72
|
+
def new(options = {})
|
|
73
|
+
Renderer.new(options)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
require "weasy_pdf/railtie" if defined?(Rails::Railtie)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module WeasyPDF
|
|
2
|
+
class Configuration
|
|
3
|
+
DEFAULTS: Hash[Symbol, untyped]
|
|
4
|
+
BINARY_CANDIDATES: Array[String]
|
|
5
|
+
|
|
6
|
+
attr_accessor exe_path: String
|
|
7
|
+
attr_accessor default_options: Hash[Symbol, untyped]
|
|
8
|
+
attr_accessor base_url: String?
|
|
9
|
+
attr_accessor timeout: Integer
|
|
10
|
+
attr_accessor temp_path: Pathname
|
|
11
|
+
|
|
12
|
+
def initialize: () -> void
|
|
13
|
+
def []: (Symbol key) -> untyped
|
|
14
|
+
def []=: (Symbol key, untyped value) -> untyped
|
|
15
|
+
end
|
|
16
|
+
end
|
data/sig/renderer.rbs
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
module WeasyPDF
|
|
2
|
+
class Renderer
|
|
3
|
+
WEASYPRINT_OPTIONS: Array[Symbol]
|
|
4
|
+
|
|
5
|
+
def initialize: (?Hash[Symbol, untyped] options) -> void
|
|
6
|
+
def pdf_from_string: (String html_string, ?Hash[Symbol, untyped] options) -> String?
|
|
7
|
+
def pdf_from_html_file: (String | Pathname filepath, ?Hash[Symbol, untyped] options) -> String?
|
|
8
|
+
def pdf_from_url: (String url, ?Hash[Symbol, untyped] options) -> String?
|
|
9
|
+
end
|
|
10
|
+
end
|
data/sig/weasy_pdf.rbs
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
module WeasyPDF
|
|
2
|
+
VERSION: String
|
|
3
|
+
|
|
4
|
+
class Error < StandardError
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
class BinaryNotFoundError < Error
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
class GenerationError < Error
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
class TimeoutError < Error
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
class MissingAsset < Error
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
class MissingLocalAsset < MissingAsset
|
|
20
|
+
attr_reader path: String
|
|
21
|
+
|
|
22
|
+
def initialize: (String path) -> void
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
class MissingRemoteAsset < MissingAsset
|
|
26
|
+
attr_reader url: String
|
|
27
|
+
attr_reader response: untyped
|
|
28
|
+
|
|
29
|
+
def initialize: (String url, untyped response) -> void
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.configure: () { (Configuration) -> void } -> void
|
|
33
|
+
def self.configuration: () -> Configuration
|
|
34
|
+
def self.config: () -> Configuration
|
|
35
|
+
def self.config=: (Hash[Symbol, untyped] hash) -> void
|
|
36
|
+
def self.clear_config: () -> Configuration
|
|
37
|
+
def self.reset!: () -> Configuration
|
|
38
|
+
def self.silence_deprecations: () -> void
|
|
39
|
+
def self.new: (?Hash[Symbol, untyped] options) -> Renderer
|
|
40
|
+
end
|