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.
@@ -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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WeasyPDF
4
+ VERSION = "0.1.0"
5
+ 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