sghtmltopdf 0.7.0-x64-mingw-ucrt

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3ee5fa24b7c3cf88df0fad8c10f969ca1bb3ceedb7734b3e73c7b5513ccdde8f
4
+ data.tar.gz: 9cea0287b96cf71414ee9e17e086c9296dc6e86657585b0188930eb6eb92765d
5
+ SHA512:
6
+ metadata.gz: 552bf52d735af9995279fd78356434958ac68db8f143c2e9489508d49f701cd1ee7f344c2216659424f997faef1339e8f8f9b40db81656c796b21c0812dfb660
7
+ data.tar.gz: 6afe30d9f30f724e224d256db40ec242d25563e42c449879cd95d82d78538f84c9732c9f8eb91174dc4e64c9b01b8c409b35873ad13d555367bcc7baa4c716d1
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 yo_waka
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,150 @@
1
+ # sghtmltopdf
2
+
3
+ Ruby binding for [sghtmltopdf](https://github.com/waka/sghtmltopdf), an HTML-to-PDF renderer written in Rust that does not depend on Chromium, WebKit, or Gecko.
4
+
5
+ The engine runs inside your process through a native extension (magnus + rb-sys) — no subprocess, no temporary files — and releases the GVL while rendering, so other Puma threads keep running.
6
+
7
+ [Documentation](https://waka.github.io/sghtmltopdf/en/usage/ruby_rails.html) · [Repository](https://github.com/waka/sghtmltopdf) · [CHANGELOG](https://github.com/waka/sghtmltopdf/blob/main/CHANGELOG.md)
8
+
9
+ ## Install
10
+
11
+ ```ruby
12
+ # Gemfile
13
+ gem "sghtmltopdf"
14
+ ```
15
+
16
+ Precompiled native gems are published for `x86_64-linux`, `aarch64-linux`, `x86_64-linux-musl`, `aarch64-linux-musl`, `arm64-darwin`, `x86_64-darwin`, and Windows `x64-mingw-ucrt`.
17
+ There is no build step on those platforms.
18
+
19
+ Elsewhere the source gem does not carry the Rust core and will refuse to build with an explanatory message.
20
+ Point those environments at a separate `sghtmltopdf server` process instead; see [Delegating to a server](#delegating-to-a-server).
21
+
22
+ Requires Ruby >= 3.2. The precompiled gems bundle extensions for Ruby 3.2, 3.3,
23
+ 3.4, and 4.0. On Windows, use 64-bit RubyInstaller UCRT (`x64-mingw-ucrt`).
24
+
25
+ For contributors building from a repository checkout on Windows, use RubyInstaller
26
+ with its Devkit, the Rust GNU toolchain (`stable-x86_64-pc-windows-gnu`), and LLVM
27
+ with `libclang.dll`. Set `RUSTUP_TOOLCHAIN=stable-x86_64-pc-windows-gnu`, set
28
+ `LIBCLANG_PATH` to the LLVM `bin` directory, add it to `PATH`, and set
29
+ `BINDGEN_EXTRA_CLANG_ARGS=--target=x86_64-w64-windows-gnu`.
30
+ Run the build through `ridk exec bundle exec rake compile`. The precompiled gem
31
+ does not need these tools.
32
+
33
+ ## Usage
34
+
35
+ ```ruby
36
+ pdf = Sghtmltopdf.render("<h1>Invoice</h1>", page_size: "A4", margin_top: "20mm")
37
+ ```
38
+
39
+ Option names are the CLI long options without `--` and with `-` replaced by `_`, so `--page-size A4` becomes `page_size: "A4"`.
40
+ The [option reference](https://waka.github.io/sghtmltopdf/en/usage/cli/reference.html) lists all of them.
41
+
42
+ Write straight to a file (written to a temporary file and renamed on success, so a failure never leaves a broken PDF behind), or take the bytes in chunks:
43
+
44
+ ```ruby
45
+ Sghtmltopdf.render_to_file(html, "invoice.pdf", page_size: "A4")
46
+
47
+ Sghtmltopdf.render(html) { |bytes| io.write(bytes) }
48
+ ```
49
+
50
+ ## Header and footer HTML
51
+
52
+ Pass markup directly without creating temporary files:
53
+
54
+ ```ruby
55
+ Sghtmltopdf.render(html,
56
+ header_html_content: '<div>Invoice [title]</div>',
57
+ footer_html_content: '<div>Page [page] of [topage]</div>')
58
+ ```
59
+
60
+ These options work with `render`, block output, `render_to_file`, global
61
+ configuration, and server delegation. The shared CLI options are
62
+ `--header-html-content` and `--footer-html-content`.
63
+
64
+ `header_html` and `footer_html` still accept file paths. Supplying both a path
65
+ and content for the same side raises `Sghtmltopdf::UsageError`, including when
66
+ one comes from global configuration. `nil` or `false` disables a configured
67
+ option; an empty string is an explicitly empty header or footer. Either HTML
68
+ form takes precedence over simple text options for that side.
69
+
70
+ Content uses the same placeholder expansion and margin clipping as file input.
71
+ Embedded `data:` images are supported; external resources remain blocked.
72
+ With server delegation, markup travels in URL query parameters, so URL length
73
+ limits may apply and request logs may contain the markup.
74
+
75
+ ## Rails
76
+
77
+ Adding the gem is enough; the Railtie wires everything up, and nothing is loaded when Rails is absent.
78
+
79
+ ```ruby
80
+ # config/initializers/sghtmltopdf.rb
81
+ Sghtmltopdf.configure do |c|
82
+ c.page_size = "A4"
83
+ c.gothic_font = Rails.root.join("vendor/fonts/NotoSansJP-Regular.ttf")
84
+ end
85
+ ```
86
+
87
+ A `:pdf` renderer is registered, in the spirit of [wicked_pdf](https://github.com/mileszs/wicked_pdf) — the same keys, so an existing controller often needs no change at all:
88
+
89
+ ```ruby
90
+ class InvoicesController < ApplicationController
91
+ def show
92
+ render pdf: "invoice", # filename; ".pdf" is appended
93
+ template: "invoices/show",
94
+ layout: "pdf",
95
+ page_size: "A4", margin_top: "20mm"
96
+ end
97
+ end
98
+ ```
99
+
100
+ View-rendering keys (`template`, `layout`, `locals`, …) go to `render_to_string`, response keys (`filename`, `disposition`, `status`) go to `send_data`, `show_as_html: true` returns the HTML instead of a PDF, and everything else is passed to the converter.
101
+
102
+ `render_to_string(pdf: "invoice", template: "invoices/show")` returns PDF bytes without setting the controller response. You can then pass those bytes to `send_data` or save them elsewhere; response options such as `filename`, `disposition`, and `status` only apply to `render pdf:`.
103
+ Converter keys are flat CLI flag names, so wicked_pdf's nested `margin: {top: 10}` becomes `margin_top: "10mm"` (with the unit spelled out); the [migration guide](https://waka.github.io/sghtmltopdf/en/migration/wicked-pdf.html) maps every key one by one.
104
+
105
+ ### Assets
106
+
107
+ PDF rendering does not go through the HTTP server, so `/assets/…` URLs are resolved as local files: the Railtie defaults `base_url` to `Rails.root/public` and restricts local reads to `public/` and the asset pipeline load paths via `allow_path`.
108
+ That is enough for a precompiled production app; in development the digested `/assets/…` path names no file on disk, so these helpers look the asset up in the pipeline instead — the CSS expanded into a `<style>`, the image referenced by the path the engine can read:
109
+
110
+ ```erb
111
+ <%= sghtmltopdf_stylesheet_link_tag "pdf" %>
112
+ <%= sghtmltopdf_image_tag "logo.png" %>
113
+ ```
114
+
115
+ `sghtmltopdf_stylesheet_link_tag` does not copy the CSS verbatim: it points every `url()` at a file the engine can read and splices in every `@import`.
116
+ The asset pipeline rewrites `url()` through `asset_path` while precompiling, which turns a `@font-face` source into a digested `/assets/…` path, or into an absolute URL once `asset_host` is set — neither can be fetched while rendering, and a `@font-face` that fails to load falls back to the engine default rather than to the next `font-family`.
117
+
118
+ A file `allow_path` does not cover is embedded as a `data:` URI instead, so that it cannot silently vanish from the PDF; pass `inline: true` to embed unconditionally.
119
+
120
+ ### Streaming the response
121
+
122
+ To send pages as soon as their layout is final, pass a block and use `ActionController::Live` — this also makes `Rack::Timeout` and `Thread#kill` effective at chunk boundaries:
123
+
124
+ ```ruby
125
+ class InvoicesController < ApplicationController
126
+ include ActionController::Live
127
+
128
+ def show
129
+ response.headers["Content-Type"] = "application/pdf"
130
+ html = render_to_string(template: "invoices/show", layout: "pdf")
131
+ Sghtmltopdf.render(html) { |bytes| response.stream.write(bytes) }
132
+ ensure
133
+ response.stream.close
134
+ end
135
+ end
136
+ ```
137
+
138
+ ## Delegating to a server
139
+
140
+ If the gem cannot run where your app runs, or you would rather not spend the app's CPU on rendering, set `server_url` and the same calls are delegated over HTTP to a separate `sghtmltopdf server` process.
141
+
142
+ ```ruby
143
+ Sghtmltopdf.configure { |c| c.server_url = "http://pdf:8080" }
144
+ ```
145
+
146
+ The [official Docker image](https://waka.github.io/sghtmltopdf/en/getting-started/docker.html) runs that server and bundles Japanese fonts.
147
+
148
+ ## License
149
+
150
+ MIT License ([LICENSE](LICENSE)).
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sghtmltopdf
4
+ # The global default options.
5
+ #
6
+ # Sghtmltopdf.configure do |c|
7
+ # c.page_size = "A4"
8
+ # c.gothic_font = "/path/to/NotoSansJP-Regular.ttf"
9
+ # end
10
+ #
11
+ # A value set here can be overridden by an argument to `render`/`render_to_file`
12
+ # (merged in the order global, then call-time).
13
+ #
14
+ # Key names are not validated. The option definitions live in one place on the Rust side
15
+ # (`cli/options.rs`), so an unknown key makes clap raise a `UsageError` at render time.
16
+ class Configuration
17
+ def initialize(options = {})
18
+ @options = {}
19
+ # Values set explicitly (@options) are kept separately from the defaults injected by
20
+ # the Railtie and others (@defaults). Reads always prefer @options, so nothing depends
21
+ # on the order the initialisers run in.
22
+ @defaults = {}
23
+ options.each { |key, value| self[key] = value }
24
+ end
25
+
26
+ def [](key)
27
+ key = Options.canonical_key(key)
28
+ @options.key?(key) ? @options[key] : @defaults[key]
29
+ end
30
+
31
+ def []=(key, value)
32
+ @options[Options.canonical_key(key)] = value
33
+ end
34
+
35
+ # @param with_defaults [Boolean] whether to include the injected defaults.
36
+ # Set it to `false` when delegating to the HTTP server: the Rails-oriented defaults
37
+ # (`base_url` and `allow`) exist for local file resolution and are keys server mode
38
+ # cannot take from a request
39
+ def to_h(with_defaults: true)
40
+ with_defaults ? @defaults.merge(@options) : @options.dup
41
+ end
42
+
43
+ # Inject the defaults. Used by the Railtie to set the Rails-oriented defaults.
44
+ # They are weaker than explicitly set values (`[]=` wins regardless of order).
45
+ def apply_defaults(defaults)
46
+ defaults.each { |key, value| @defaults[Options.canonical_key(key)] = value }
47
+ self
48
+ end
49
+
50
+ # Accepts both `c.page_size = "A4"` and `c.page_size`.
51
+ def method_missing(name, *args)
52
+ key = name.to_s
53
+ if key.end_with?("=")
54
+ raise ArgumentError, "#{name} takes one argument" unless args.size == 1
55
+
56
+ self[key.chomp("=")] = args.first
57
+ else
58
+ raise ArgumentError, "#{name} takes no arguments" unless args.empty?
59
+
60
+ self[key]
61
+ end
62
+ end
63
+
64
+ def respond_to_missing?(_name, _include_private = false)
65
+ true
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ # `Time#iso8601` comes from here before Ruby 3.4 (Rails loads it, a plain Ruby script may not).
5
+ require "time"
6
+ require "uri"
7
+
8
+ module Sghtmltopdf
9
+ # Converts an options hash into:
10
+ #
11
+ # * the CLI argument list (argv) passed to the native extension ... [.to_argv]
12
+ # * the query string passed to HTTP server mode ... [.to_query]
13
+ module Options
14
+
15
+ # The keys interpreted on the Ruby side alone. They are not conversion options, so they
16
+ # appear in neither the argv nor the query.
17
+ TRANSPORT_KEYS = %i[server_url server_open_timeout server_read_timeout chunk_size].freeze
18
+
19
+ # Alias keys (values are the canonical names). The CLI accepts `--allow` as an
20
+ # alias of `--allow-path`, but the Ruby side must not carry both keys around.
21
+ # If the defaults use one key and the call uses the other, a hash merge does
22
+ # not override: both end up in argv (repeating the same flag means "union",
23
+ # not "replace").
24
+ ALIAS_KEYS = {allow: :allow_path}.freeze
25
+
26
+ module_function
27
+
28
+ # Map an alias key to its canonical name.
29
+ def canonical_key(key)
30
+ key = key.to_sym
31
+ ALIAS_KEYS.fetch(key, key)
32
+ end
33
+
34
+ # Normalize all keys of a hash.
35
+ def canonicalize(options)
36
+ options.to_h { |key, value| [canonical_key(key), value] }
37
+ end
38
+
39
+ # @param options [Hash] the Ruby options hash
40
+ # @return [Array<String>] the conversion options passed to the core's `Converter`.
41
+ # The HTML and the destination travel over FFI, so there is no program name, input
42
+ # path or `--output` here.
43
+ def to_argv(options)
44
+ argv = []
45
+ each_pair(options) do |name, value|
46
+ argv.push("--#{name}")
47
+ argv.push(value) unless value.nil?
48
+ end
49
+ argv
50
+ end
51
+
52
+ # @param options [Hash] the Ruby options hash
53
+ # @return [String] the query string for `POST /pdf` (with no leading `?`)
54
+ def to_query(options)
55
+ parts = []
56
+ each_pair(options) do |name, value|
57
+ # A valueless flag becomes just the key (the server treats no value as true).
58
+ parts << (value.nil? ? escape(name) : "#{escape(name)}=#{escape(value)}")
59
+ end
60
+ parts.join("&")
61
+ end
62
+
63
+ # Convert one key and value into an argv fragment.
64
+ #
65
+ # page_size: "A4" → ["--page-size", "A4"]
66
+ # grayscale: true → ["--grayscale"]
67
+ # grayscale: false → []
68
+ # allow: ["/a", "/b"] → ["--allow", "/a", "--allow", "/b"]
69
+ def args_for(key, value)
70
+ pairs_for(key, value).flat_map { |name, arg| arg.nil? ? ["--#{name}"] : ["--#{name}", arg] }
71
+ end
72
+
73
+ # Turn one key and value into a list of "flag name and value" pairs. A pair whose value is
74
+ # `nil` is a flag taking no value (`--toc` and the like).
75
+ def pairs_for(key, value)
76
+ name = flag_name(key)
77
+ return font_pairs(value) if name == "font"
78
+
79
+ case value
80
+ when nil, false then []
81
+ when true then [[name, nil]]
82
+ # An array means the same option repeated. The same rule applies to each element.
83
+ when Array then value.flat_map { |element| pairs_for(key, element) }
84
+ # `creation_date:` takes a moment in time. The CLI wants it as ISO 8601, so it is
85
+ # converted to UTC here rather than left to `to_s`, which would drop the zone for a Time
86
+ # and the subsecond part of a DateTime.
87
+ when Time, DateTime then [[name, value.to_time.getutc.iso8601]]
88
+ when Date then [[name, value.iso8601]]
89
+ when Hash
90
+ # Nesting such as wicked_pdf's `margin: {top: 10}` is not accepted. There is no
91
+ # corresponding CLI flag, and flattening it mechanically would let even misspelled
92
+ # keys through silently. When migrating, use the correspondence table in the
93
+ # migration guide to rewrite them. A unitless number is read as mm, as in wicked_pdf (`cli/units.rs`).
94
+ example = value.keys.first
95
+ raise ArgumentError,
96
+ "a Hash cannot be passed for #{key} (only :font takes a path and an index). " \
97
+ "Give nested options as flat keys" \
98
+ "#{": for example #{key}_#{example}: \"...\"" if example}"
99
+ else [[name, value.to_s]]
100
+ end
101
+ end
102
+
103
+ # `--font` and `--font-index` are paired by their order of appearance (the CLI ties each
104
+ # `--font-index` to "the last `--font` before it" via `ArgMatches#indices_of`).
105
+ # So a face index always goes immediately after its own `--font`.
106
+ #
107
+ # font: "a.ttf" → ["--font", "a.ttf"]
108
+ # font: {path: "a.ttc", index: 1} → ["--font", "a.ttc", "--font-index", "1"]
109
+ # font: ["a.ttf", {path: "b.ttc", index: 2}]
110
+ # → ["--font", "a.ttf", "--font", "b.ttc", "--font-index", "2"]
111
+ def font_args(value)
112
+ font_pairs(value).flat_map { |name, arg| ["--#{name}", arg] }
113
+ end
114
+
115
+ def font_pairs(value)
116
+ case value
117
+ when nil, false then []
118
+ when Array then value.flat_map { |element| font_pairs(element) }
119
+ when Hash
120
+ path = value[:path] || value["path"]
121
+ raise ArgumentError, "a font Hash needs a path: #{value.inspect}" if path.nil?
122
+
123
+ index = value[:index] || value["index"]
124
+ pairs = [["font", path.to_s]]
125
+ pairs << ["font-index", index.to_s] unless index.nil?
126
+ pairs
127
+ else [["font", value.to_s]]
128
+ end
129
+ end
130
+
131
+ # `:page_size` becomes `page-size`.
132
+ def flag_name(key)
133
+ key.to_s.tr("_", "-")
134
+ end
135
+
136
+ # Enumerate only the conversion options, as pairs, in the order they were given.
137
+ def each_pair(options, &block)
138
+ options.each do |key, value|
139
+ key = canonical_key(key)
140
+ next if TRANSPORT_KEYS.include?(key)
141
+
142
+ pairs_for(key, value).each(&block)
143
+ end
144
+ end
145
+
146
+ def escape(value)
147
+ URI.encode_www_form_component(value.to_s)
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+ require_relative "renderer"
5
+ require_relative "view_helpers"
6
+
7
+ module Sghtmltopdf
8
+ class Railtie < ::Rails::Railtie
9
+ # The directories the engine may read from: `public/`, where the
10
+ # precompiled assets are, plus the asset pipeline load paths, where their
11
+ # sources are in development. The pipeline paths reach outside `Rails.root`
12
+ # for the assets a gem or an engine provides, which is why the whole of
13
+ # `Rails.root` is not a substitute for them.
14
+ #
15
+ # `config.assets` raises rather than answering `nil` when no pipeline gem is
16
+ # installed, hence `try`.
17
+ def self.default_options(app)
18
+ public_dir = File.join(app.root.to_s, "public")
19
+ pipeline = Array(app.config.try(:assets)&.paths).map(&:to_s)
20
+ allow = [public_dir, *pipeline].select { |dir| File.directory?(dir) }.uniq
21
+
22
+ defaults = {}
23
+ # An empty list would mean "no --allow-path", which is not the same thing as
24
+ # "allow nothing": it hands the boundary back to `base_url`. Leaving the
25
+ # key out says that, and says it in one place.
26
+ defaults[:allow_path] = allow unless allow.empty?
27
+ defaults[:base_url] = public_dir if File.directory?(public_dir)
28
+ defaults
29
+ end
30
+
31
+ # Read in `after_initialize`, not inside the initializer. The pipeline fills
32
+ # `config.assets.paths` in its own initializer (for Propshaft,
33
+ # `propshaft.append_assets_path`), and that one runs later.
34
+ #
35
+ # This runs after `config/initializers`, but `apply_defaults` is always
36
+ # weaker than explicitly set values, so it never overrides user settings.
37
+ initializer "sghtmltopdf.defaults" do |app|
38
+ app.config.after_initialize do
39
+ Sghtmltopdf.config.apply_defaults(Sghtmltopdf::Railtie.default_options(app))
40
+ end
41
+ end
42
+
43
+ initializer "sghtmltopdf.renderer" do
44
+ ActiveSupport.on_load(:action_controller) do
45
+ Sghtmltopdf::Renderer.register!
46
+ end
47
+
48
+ ActiveSupport.on_load(:action_view) do
49
+ include Sghtmltopdf::ViewHelpers
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sghtmltopdf
4
+ # Sorts the options of `render pdf: "invoice"` into three groups:
5
+ #
6
+ # * those passed to Rails view rendering (`render_to_string`)
7
+ # * those passed to building the response (`send_data`)
8
+ # * those passed to PDF conversion (`Sghtmltopdf.render`)
9
+ #
10
+ #
11
+ # It is a pure Ruby class with no dependency on Rails, so it can be unit tested without Rails.
12
+ class Renderer
13
+ module RenderToString
14
+ def render_to_string(*args, &block)
15
+ options = args.first.is_a?(Hash) ? args.first : args[1]
16
+ return super unless options.is_a?(Hash) && options.key?(:pdf)
17
+
18
+ normalized_args = args.map { |arg| arg.is_a?(Hash) ? arg.dup : arg }
19
+ options = _normalize_args(*normalized_args, &block)
20
+ renderer = Renderer.new(options.delete(:pdf), options, default_name: action_name)
21
+ html = super(**renderer.render_options, &block)
22
+ renderer.body_for(html)
23
+ end
24
+ end
25
+
26
+ # The keys passed straight to `render_to_string`.
27
+ RAILS_RENDER_KEYS = %i[
28
+ action assigns body collection file formats handlers html inline layout
29
+ locals object partial plain prefixes template variants
30
+ ].freeze
31
+
32
+ # The keys used to build the response (passed to `send_data`).
33
+ RESPONSE_KEYS = %i[disposition filename status].freeze
34
+
35
+ # The keys the renderer itself interprets (for debugging: return the HTML rather than a PDF).
36
+ RENDERER_KEYS = %i[show_as_html].freeze
37
+
38
+ PDF_CONTENT_TYPE = "application/pdf"
39
+ HTML_CONTENT_TYPE = "text/html"
40
+
41
+ attr_reader :name, :options
42
+
43
+ # @param name [String, Symbol, nil] the value given to `pdf:` (the basis of the file name)
44
+ # @param options [Hash] the other options given to `render`
45
+ # @param default_name [String, nil] the file name when `name` is empty
46
+ # (the controller's `action_name` is expected)
47
+ def initialize(name, options = {}, default_name: nil)
48
+ @name = blank?(name) ? (default_name || "document").to_s : name.to_s
49
+ @options = options.to_h { |key, value| [key.to_sym, value] }
50
+ end
51
+
52
+ # Register the renderer with `ActionController::Renderers.add(:pdf)`.
53
+ # Called from the Railtie's Action Controller load hook (`on_load`).
54
+ def self.register!
55
+ ::ActionController::Rendering.prepend(RenderToString)
56
+ ::ActionController::Renderers.add(:pdf) do |name, options|
57
+ renderer = ::Sghtmltopdf::Renderer.new(name, options, default_name: action_name)
58
+ html = render_to_string(**renderer.render_options)
59
+ send_data(renderer.body_for(html), **renderer.send_data_options)
60
+ end
61
+ end
62
+
63
+ # The options used to render the view.
64
+ def render_options
65
+ options.select { |key, _| RAILS_RENDER_KEYS.include?(key) }
66
+ end
67
+
68
+ # The options used for the PDF conversion.
69
+ def convert_options
70
+ known = RAILS_RENDER_KEYS + RESPONSE_KEYS + RENDERER_KEYS
71
+ options.reject { |key, _| known.include?(key) }
72
+ end
73
+
74
+ # Turn the rendered HTML into the response body.
75
+ def body_for(html)
76
+ show_as_html? ? html : Sghtmltopdf.render(html, **convert_options)
77
+ end
78
+
79
+ def send_data_options
80
+ opts = {type: content_type, disposition: disposition}
81
+ opts[:filename] = filename unless show_as_html?
82
+ opts[:status] = options[:status] if options.key?(:status)
83
+ opts
84
+ end
85
+
86
+ def content_type
87
+ show_as_html? ? HTML_CONTENT_TYPE : PDF_CONTENT_TYPE
88
+ end
89
+
90
+ # `filename: "x.pdf"` wins over `pdf: "x"`. The extension is not doubled up.
91
+ def filename
92
+ base = blank?(options[:filename]) ? name : options[:filename].to_s
93
+ base.downcase.end_with?(".pdf") ? base : "#{base}.pdf"
94
+ end
95
+
96
+ # The default is `inline` (opened in the browser), as in wicked_pdf.
97
+ def disposition
98
+ blank?(options[:disposition]) ? "inline" : options[:disposition].to_s
99
+ end
100
+
101
+ # The equivalent of wicked_pdf's `show_as_html`. It returns the HTML rather than a PDF, so
102
+ # the layout can be inspected in the browser's developer tools.
103
+ def show_as_html?
104
+ value = options[:show_as_html]
105
+ !(value.nil? || value == false || value == "false")
106
+ end
107
+
108
+ private
109
+
110
+ def blank?(value)
111
+ value.nil? || (value.respond_to?(:empty?) && value.empty?)
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+
6
+ module Sghtmltopdf
7
+ class ServerError < Error; end
8
+
9
+ # The client delegating conversion to HTTP server mode (`sghtmltopdf server`).
10
+ #
11
+ # Sghtmltopdf.configure { |c| c.server_url = "http://pdf.internal:8080" }
12
+ # pdf = Sghtmltopdf.render(html, page_size: "A4")
13
+ class ServerClient
14
+ DEFAULT_OPEN_TIMEOUT = 5
15
+ DEFAULT_READ_TIMEOUT = 120
16
+
17
+ # The rough size of each read. With `?stream=1` this is the unit handed to the block.
18
+ CHUNK_SIZE = 64 * 1024
19
+
20
+ attr_reader :uri, :open_timeout, :read_timeout
21
+
22
+ # @param url [String] the server's base URL (`http://host:port`)
23
+ def initialize(url, open_timeout: nil, read_timeout: nil)
24
+ @uri = parse(url)
25
+ @open_timeout = (open_timeout || DEFAULT_OPEN_TIMEOUT).to_f
26
+ @read_timeout = (read_timeout || DEFAULT_READ_TIMEOUT).to_f
27
+ end
28
+
29
+ # Convert HTML to PDF.
30
+ #
31
+ # Given a block it uses `?stream=1` (chunked transfer encoding) and hands over chunks as
32
+ # the server settles each page. With no block it returns the whole PDF as a String.
33
+ def render(html, options, &block)
34
+ request = build_request(html, options, stream: !block.nil?)
35
+ pdf = nil
36
+ start do |http|
37
+ # With a block, `request` returns the response object, so the result is received in an
38
+ # outer variable.
39
+ http.request(request) do |response|
40
+ ensure_success!(response)
41
+ if block
42
+ response.read_body { |chunk| block.call(chunk.b) }
43
+ else
44
+ pdf = read_all(response)
45
+ end
46
+ end
47
+ end
48
+ pdf
49
+ end
50
+
51
+ # Write the conversion result to `path`. To leave no broken PDF on a failure part-way
52
+ # through, it writes to a temporary file and renames (matching the behaviour of the
53
+ # native extension's `FileSink`).
54
+ def render_to_file(html, options, path)
55
+ tmp = "#{path}.#{Process.pid}.tmp"
56
+ begin
57
+ File.open(tmp, "wb") do |file|
58
+ render(html, options) { |chunk| file.write(chunk) }
59
+ end
60
+ rescue SystemCallError => e
61
+ File.unlink(tmp) if File.exist?(tmp)
62
+ raise InputError, "failed to write to #{path}: #{e.message}"
63
+ rescue StandardError
64
+ File.unlink(tmp) if File.exist?(tmp)
65
+ raise
66
+ end
67
+ File.rename(tmp, path)
68
+ nil
69
+ end
70
+
71
+ private
72
+
73
+ def parse(url)
74
+ uri = URI.parse(url.to_s)
75
+ unless uri.is_a?(URI::HTTP) && uri.host
76
+ raise ArgumentError, "server_url must be an http(s) URL: #{url.inspect}"
77
+ end
78
+
79
+ uri
80
+ end
81
+
82
+ def build_request(html, options, stream:)
83
+ query = Options.to_query(options)
84
+ query = stream ? [query, "stream=1"].reject(&:empty?).join("&") : query
85
+ target = uri.dup
86
+ target.path = "/pdf"
87
+ target.query = query.empty? ? nil : query
88
+
89
+ request = Net::HTTP::Post.new(target)
90
+ request["Content-Type"] = "text/html; charset=utf-8"
91
+ request.body = html.to_s.b
92
+ request
93
+ end
94
+
95
+ def start(&block)
96
+ Net::HTTP.start(
97
+ uri.host, uri.port,
98
+ use_ssl: uri.scheme == "https",
99
+ open_timeout: open_timeout,
100
+ read_timeout: read_timeout,
101
+ &block
102
+ )
103
+ rescue Net::OpenTimeout, Net::ReadTimeout => e
104
+ raise ServerError, "the connection to #{base} timed out: #{e.class}"
105
+ rescue SocketError, SystemCallError, IOError, OpenSSL::SSL::SSLError => e
106
+ raise ServerError, "the connection to #{base} failed: #{e.message}"
107
+ end
108
+
109
+ # An error response's body is a `text/plain` message (the same wording as the CLI).
110
+ def ensure_success!(response)
111
+ return if response.is_a?(Net::HTTPOK)
112
+
113
+ message = read_all(response).force_encoding(Encoding::UTF_8).strip
114
+ raise error_class(response), "#{base}: #{message}"
115
+ end
116
+
117
+ def error_class(response)
118
+ case response.code.to_i
119
+ when 400 then UsageError
120
+ when 413 then InputError
121
+ when 500 then RenderError
122
+ # A 404 or 405 means a wrong path or method, most likely because the other end is not
123
+ # an sghtmltopdf server. A 503 or 504 means the queue overflowed or the queue wait timed out.
124
+ else ServerError
125
+ end
126
+ end
127
+
128
+ def read_all(response)
129
+ buffer = +""
130
+ response.read_body { |chunk| buffer << chunk }
131
+ buffer.b
132
+ end
133
+
134
+ def base
135
+ "#{uri.scheme}://#{uri.host}:#{uri.port}"
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sghtmltopdf
4
+ VERSION = "0.7.0"
5
+ end
@@ -0,0 +1,405 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sghtmltopdf
4
+ # Action View helpers.
5
+ #
6
+ # Rendering a PDF never goes through an HTTP server, so a URL such as
7
+ # `/assets/…` is resolved as a local file (`--base-url` defaults to
8
+ # `Rails.root/public`, see [Railtie.default_options]). In production, where
9
+ # the assets are precompiled, that makes a plain `stylesheet_link_tag` work as
10
+ # it is; in development, where nothing has been written to `public/` yet, it
11
+ # cannot be resolved: the digest and the `/assets/` mount point are made up by
12
+ # the pipeline at request time, so no file of that name is on disk.
13
+ #
14
+ # Hence helpers that look the asset up in the pipeline and hand the engine
15
+ # something it can actually read:
16
+ #
17
+ # <%= sghtmltopdf_stylesheet_link_tag "pdf" %>
18
+ # <%= sghtmltopdf_image_tag "logo.png" %>
19
+ #
20
+ # (the counterparts of wicked_pdf's `wicked_pdf_stylesheet_link_tag` and
21
+ # `wicked_pdf_image_tag`).
22
+ module ViewHelpers
23
+ # Local file path of an asset, or `nil` when it cannot be found.
24
+ #
25
+ # Looks in
26
+ #
27
+ # 1. `public/` (precompiled; production)
28
+ # 2. the asset pipeline load paths (development; Propshaft or Sprockets)
29
+ #
30
+ # in that order. The pipeline lookup goes through `respond_to?` so that
31
+ # neither gem becomes a dependency (best effort).
32
+ def sghtmltopdf_asset_path(source)
33
+ path = source.to_s
34
+ # A source that is already a URL (or a `data:` URI) is not an asset of
35
+ # this application. Without this, `from_public_dir` would strip the host
36
+ # off `https://example.com/logo.png` and hand back `public/logo.png`.
37
+ return nil if path.match?(%r{\A(?:[a-z][a-z0-9+.\-]*:|//)}i)
38
+ return path if path.start_with?("/") && File.file?(path)
39
+
40
+ from_public_dir(path) || from_asset_pipeline(path)
41
+ end
42
+
43
+ # Expands the CSS itself into a `<style>`. Takes several sources; any that
44
+ # cannot be found are skipped silently (rendering still goes ahead).
45
+ #
46
+ # The file is not copied verbatim: its `url()`s are pointed at files the
47
+ # engine can read and its `@import`s are spliced in, see
48
+ # [#inline_stylesheet].
49
+ def sghtmltopdf_stylesheet_link_tag(*sources)
50
+ css = sources.flatten.filter_map do |source|
51
+ path = sghtmltopdf_asset_path(with_extension(source, ".css"))
52
+ inline_stylesheet(path) if path
53
+ end
54
+ return "".html_safe if css.empty?
55
+
56
+ content_tag(:style, css.join("\n").html_safe, type: "text/css")
57
+ end
58
+
59
+ # An `<img>` whose `src` the engine can read.
60
+ #
61
+ # The file is referenced by path, which keeps the HTML small: relative to
62
+ # `base_url` when it sits under it (the precompiled case), and the absolute
63
+ # filesystem path otherwise (the development case, where the file is still
64
+ # in the pipeline's load path). The engine reads an absolute `src` as a
65
+ # filesystem path once it fails to resolve under `base_url` (#44), so both
66
+ # forms reach the same file.
67
+ #
68
+ # A path only works if the engine is allowed to read there, so it is used
69
+ # only when `allow_path` (or `base_url`, with none set) covers the file.
70
+ # Everything else falls back to a `data:` URI, which depends on no
71
+ # configuration at all and therefore cannot fail: without that fallback a
72
+ # blocked path would just disappear from the PDF, since a failed asset fetch
73
+ # is ignored by default.
74
+ #
75
+ # `inline: true` forces embedding.
76
+ def sghtmltopdf_image_tag(source, options = {})
77
+ options = options.symbolize_keys
78
+ inline = options.delete(:inline)
79
+ path = sghtmltopdf_asset_path(source)
80
+ # Not an asset of this application (a remote URL, say): leave it to Rails.
81
+ return image_tag(source, options) if path.nil?
82
+
83
+ if !inline && (src = engine_readable_src(path))
84
+ # `image_tag` would rewrite the source (prefixing `/images/`, the asset
85
+ # host, and so on), so the tag is built directly. That skips the one
86
+ # option `image_tag` does more than copy, so `size:` is expanded here.
87
+ return tag.img(**expand_size(options).merge(src: src))
88
+ end
89
+ # A `data:` URI matches `AssetUrlHelper::URI_REGEXP`, so `image_tag`
90
+ # passes it through untouched and its own options keep working.
91
+ image_tag(data_uri(path), options)
92
+ end
93
+
94
+ private
95
+
96
+ # `path` read as CSS, with every `url()` pointed at a file the engine can
97
+ # read and every `@import` spliced in.
98
+ #
99
+ # Both have to happen here because the engine keeps one base for the whole
100
+ # document: every CSS source is concatenated before it is parsed, so a
101
+ # `url()` is resolved against the HTML's `base_url` whatever stylesheet it
102
+ # came from. Only this side knows where each file sits on disk, so each
103
+ # one's references are resolved against its own directory before its text
104
+ # is spliced into the next.
105
+ #
106
+ # What has to be undone is mostly the asset pipeline's own work: it
107
+ # rewrites every `url()` through `asset_path` while precompiling, which
108
+ # yields a digested `/assets/…` path, or an absolute URL once `asset_host`
109
+ # is set. Rendering never goes through the HTTP server, so neither can be
110
+ # fetched.
111
+ #
112
+ # `chain` holds the files already being expanded, innermost last.
113
+ def inline_stylesheet(path, chain = [])
114
+ css = File.read(path)
115
+ dir = File.dirname(path)
116
+ chain += [real_path(path)].compact
117
+ comments = comment_ranges(css)
118
+ out = +""
119
+ cursor = 0
120
+ while (import = IMPORT_STATEMENT.match(css, cursor))
121
+ commented = comments.any? { |range| range.cover?(import.begin(0)) }
122
+ expanded = imported_stylesheet(import[:href], dir, chain) unless commented
123
+ out << rewrite_css_urls(css[cursor...import.begin(0)].to_s, dir)
124
+ cursor = import.end(0)
125
+ out << (expanded || import[0])
126
+ end
127
+ out << rewrite_css_urls(css[cursor..].to_s, dir)
128
+ end
129
+
130
+ # The expanded content of an `@import` target, or `nil` to leave the
131
+ # statement as it is, which hands it to the engine untouched. That happens
132
+ # when it names no asset of this application, when the nesting is too deep,
133
+ # and when it points back at a file already being expanded.
134
+ #
135
+ # Media conditions on the statement are dropped. The engine replaces the
136
+ # whole statement with the imported text too, so nothing changes by doing
137
+ # it here.
138
+ def imported_stylesheet(href, dir, chain)
139
+ return nil if chain.length >= MAX_IMPORT_DEPTH
140
+
141
+ target = css_url_target(unquote(href), dir)
142
+ return nil if target.nil?
143
+
144
+ real = real_path(target)
145
+ return nil if real.nil? || chain.include?(real)
146
+
147
+ inline_stylesheet(target, chain)
148
+ end
149
+
150
+ # Byte ranges of the `/* … */` comments in `css`. A commented-out `@import`
151
+ # must not be spliced in.
152
+ def comment_ranges(css)
153
+ css.enum_for(:scan, CSS_COMMENT).map { Regexp.last_match.begin(0)...Regexp.last_match.end(0) }
154
+ end
155
+
156
+ # Every `url()` in `css` pointed at something the engine can read: a path
157
+ # when it may read the file there, a `data:` URI when it may not.
158
+ #
159
+ # A reference that names no file of this application is left alone, which
160
+ # covers `data:` URIs, bare fragments, and genuinely remote resources such
161
+ # as a font served by a CDN.
162
+ def rewrite_css_urls(css, dir)
163
+ css.gsub(CSS_URL) do
164
+ match = Regexp.last_match
165
+ target = css_url_target(unquote(match[:href]), dir)
166
+ target ? css_url(engine_readable_src(target) || data_uri(target)) : match[0]
167
+ end
168
+ end
169
+
170
+ # The local file a CSS reference names, or `nil` when it names none.
171
+ def css_url_target(ref, dir)
172
+ path = ref.split(/[?#]/, 2).first.to_s
173
+ return nil if path.empty? || path.match?(/\Adata:/i)
174
+
175
+ if path.match?(%r{\A(?:[a-z][a-z0-9+.\-]*:|//)}i)
176
+ # Only an http(s) or protocol-relative URL can be one of ours. The host
177
+ # is not compared against `asset_host`: it may be a callable or carry a
178
+ # `%d` wildcard, so there is no general way to recognise it. Finding the
179
+ # path on disk is the test instead, and what that finds is the very file
180
+ # the reference names.
181
+ return nil unless path.match?(%r{\A(?:https?:)?//}i)
182
+
183
+ path = path.sub(%r{\A(?:https?:)?//[^/]*}i, "")
184
+ return nil if path.empty?
185
+ end
186
+
187
+ path.start_with?("/") ? from_site_root(path) : from_stylesheet_dir(path, dir)
188
+ end
189
+
190
+ # A site-root-relative reference (`/assets/pdf-<digest>.css`) mapped onto a
191
+ # file. `relative_url_root` comes off first: the pipeline writes it in front
192
+ # of every URL, but it is a mount point, not a directory under `public/`.
193
+ def from_site_root(path)
194
+ root = ::Rails.application.config.try(:relative_url_root).to_s
195
+ path = path.delete_prefix(root) unless root.empty?
196
+ candidate = File.join(::Rails.public_path.to_s, path)
197
+ return candidate if File.file?(candidate)
198
+
199
+ # Nothing is precompiled in development, so the load path is asked as
200
+ # well. The mount point is not part of a logical path either.
201
+ logical = path.delete_prefix("/")
202
+ prefix = ::Rails.application.config.try(:assets)&.prefix.to_s.delete_prefix("/")
203
+ from_asset_pipeline(logical) ||
204
+ (prefix.empty? ? nil : from_asset_pipeline(logical.delete_prefix("#{prefix}/")))
205
+ end
206
+
207
+ # A reference relative to the stylesheet that wrote it, which is what CSS
208
+ # says it means and what the pipeline assumed while compiling the file.
209
+ #
210
+ # The load path is tried too. An uncompiled stylesheet sitting at the root
211
+ # of its own load path names assets by logical path, which is spelled the
212
+ # same way but is looked up across every root.
213
+ def from_stylesheet_dir(path, dir)
214
+ candidate = File.expand_path(path, dir)
215
+ return candidate if File.file?(candidate)
216
+
217
+ from_asset_pipeline(path.delete_prefix("./"))
218
+ end
219
+
220
+ # A `url()` whose argument is always quoted, so that a path holding a space
221
+ # or a parenthesis survives.
222
+ def css_url(value)
223
+ %(url("#{value.gsub(/["\\]/) { |char| "\\#{char}" }}"))
224
+ end
225
+
226
+ def unquote(value)
227
+ value = value.to_s.strip
228
+ quoted = value.match(/\A(["'])(.*)\1\z/m)
229
+ quoted ? quoted[2] : value
230
+ end
231
+
232
+ # Matches the engine's own cap (`core/src/style/import.rs`). Past it the
233
+ # statement is left in place and the engine decides what to do with it.
234
+ MAX_IMPORT_DEPTH = 16
235
+ private_constant :MAX_IMPORT_DEPTH
236
+
237
+ # `@import` up to its terminating `;`, in both the `url()` and the bare
238
+ # string form. Media conditions are matched so they are consumed, not kept.
239
+ IMPORT_STATEMENT = /
240
+ @import \s+
241
+ (?: url\( \s* (?<href> "[^"]*" | '[^']*' | [^)"'\s]* ) \s* \)
242
+ | (?<href> "[^"]*" | '[^']*' ) )
243
+ [^;]* ;
244
+ /xi
245
+ private_constant :IMPORT_STATEMENT
246
+
247
+ # A `url()` token. The lookbehind keeps identifiers ending in "url" out.
248
+ CSS_URL = /(?<![\w-])url\( \s* (?<href> "[^"]*" | '[^']*' | [^)"'\s]* ) \s* \)/xi
249
+ private_constant :CSS_URL
250
+
251
+ CSS_COMMENT = %r{/\*.*?\*/}m
252
+ private_constant :CSS_COMMENT
253
+
254
+ # Takes the path part of the URL `asset_path` returns (which may carry an
255
+ # asset host) and maps it onto a real file under `public/`.
256
+ def from_public_dir(source)
257
+ relative = asset_url_for(source).to_s.sub(%r{\Ahttps?://[^/]+}, "").split(/[?#]/).first.to_s
258
+ return nil if relative.empty?
259
+
260
+ candidate = File.join(::Rails.public_path.to_s, relative)
261
+ File.file?(candidate) ? candidate : nil
262
+ end
263
+
264
+ # `asset_path` for `source`, falling back to `source` itself.
265
+ #
266
+ # Both pipelines raise rather than return a path for an asset outside their
267
+ # load path (`Propshaft::MissingAssetError`,
268
+ # `Sprockets::Rails::Helper::AssetNotFound`). A file that lives only in
269
+ # `public/` is exactly that, so the raw source is tried against `public/`.
270
+ def asset_url_for(source)
271
+ return source unless respond_to?(:asset_path)
272
+
273
+ asset_path(source)
274
+ rescue StandardError
275
+ source
276
+ end
277
+
278
+ def from_asset_pipeline(source)
279
+ assets = ::Rails.application.try(:assets)
280
+ return nil if assets.nil?
281
+
282
+ # Propshaft
283
+ if assets.respond_to?(:load_path)
284
+ found = assets.load_path.find(source)
285
+ return found.path.to_s if found.respond_to?(:path)
286
+ end
287
+ # Sprockets
288
+ if assets.respond_to?(:[])
289
+ found = assets[source]
290
+ return found.filename.to_s if found.respond_to?(:filename)
291
+ end
292
+ nil
293
+ end
294
+
295
+ # `data:<type>;base64,<data>` for a local file. `pack("m0")` is strict
296
+ # base64 (no line breaks) and needs no require, unlike `Base64`, which is no
297
+ # longer a default gem on Ruby 3.4.
298
+ def data_uri(path)
299
+ base64 = [File.binread(path)].pack("m0")
300
+ "data:#{mime_type_of(path)};base64,#{base64}"
301
+ end
302
+
303
+ # The `src` to reference `file` by, or `nil` when the engine would not be
304
+ # allowed to read it there.
305
+ def engine_readable_src(file)
306
+ return nil unless engine_can_read?(file)
307
+
308
+ relative_to_base_url(file) || real_path(file)
309
+ end
310
+
311
+ # Whether the engine's rules for local files let it read `file`.
312
+ #
313
+ # `allow_path` decides on its own once it is set: the engine stops treating
314
+ # `base_url` as the boundary and consults the allowed directories only.
315
+ # A run that has no local access at all, or one delegated to a server that
316
+ # may not even share this filesystem, can read nothing here.
317
+ def engine_can_read?(file)
318
+ config = Sghtmltopdf.config
319
+ return false if config[:disable_local_file_access] || config[:server_url]
320
+
321
+ file = real_path(file)
322
+ return false if file.nil?
323
+
324
+ dirs = Array(config[:allow_path]).filter_map { |dir| real_path(dir) }
325
+ dirs = [real_path(config[:base_url])].compact if dirs.empty?
326
+ dirs.any? { |dir| file.start_with?(dir + File::SEPARATOR) }
327
+ end
328
+
329
+ # The path of `file` relative to the configured `base_url`, or `nil` when
330
+ # `base_url` is not a directory holding it (it may be an http(s) URL, or the
331
+ # file may live outside it, as an asset pipeline one in development does).
332
+ def relative_to_base_url(file)
333
+ base = real_path(Sghtmltopdf.config[:base_url])
334
+ full = real_path(file)
335
+ return nil if base.nil? || full.nil?
336
+
337
+ prefix = base + File::SEPARATOR
338
+ full.start_with?(prefix) ? full.delete_prefix(prefix) : nil
339
+ end
340
+
341
+ # `path` with symlinks resolved, or `nil` when it is not a local path at
342
+ # all. The engine canonicalizes both sides before comparing them, so a
343
+ # symlink pointing out of an allowed directory does not count as inside it.
344
+ def real_path(path)
345
+ path = path.to_s
346
+ return nil if path.empty? || path.match?(%r{\Ahttps?://}i)
347
+
348
+ File.realpath(path)
349
+ rescue SystemCallError
350
+ nil
351
+ end
352
+
353
+ # `image_tag`'s `size:` shorthand: "40x30", or "40" for a square. The tag is
354
+ # built without `image_tag` here, so the expansion has to happen here too.
355
+ def expand_size(options)
356
+ return options unless options.key?(:size)
357
+
358
+ if options[:height] || options[:width]
359
+ raise ArgumentError, "Cannot pass a :size option with a :height or :width option"
360
+ end
361
+
362
+ size = options[:size].to_s
363
+ options = options.except(:size)
364
+ case size
365
+ when /\A(\d+(?:\.\d+)?)x(\d+(?:\.\d+)?)\z/ then options.merge(width: $1, height: $2)
366
+ when /\A\d+(?:\.\d+)?\z/ then options.merge(width: size, height: size)
367
+ else options
368
+ end
369
+ end
370
+
371
+ # Media type from the file extension. The engine detects the format from the
372
+ # bytes, so this only has to be honest, not exhaustive.
373
+ def mime_type_of(path)
374
+ MIME_TYPES.fetch(File.extname(path).downcase.delete_prefix("."), "application/octet-stream")
375
+ end
376
+
377
+ MIME_TYPES = {
378
+ "png" => "image/png",
379
+ "jpg" => "image/jpeg",
380
+ "jpeg" => "image/jpeg",
381
+ "gif" => "image/gif",
382
+ "webp" => "image/webp",
383
+ "avif" => "image/avif",
384
+ "bmp" => "image/bmp",
385
+ "ico" => "image/vnd.microsoft.icon",
386
+ "tif" => "image/tiff",
387
+ "tiff" => "image/tiff",
388
+ "svg" => "image/svg+xml",
389
+ # Reached through a stylesheet's `url()`, not through `image_tag`.
390
+ "otf" => "font/otf",
391
+ "ttf" => "font/ttf",
392
+ "ttc" => "font/collection",
393
+ "woff" => "font/woff",
394
+ "woff2" => "font/woff2",
395
+ "eot" => "application/vnd.ms-fontobject",
396
+ "css" => "text/css"
397
+ }.freeze
398
+ private_constant :MIME_TYPES
399
+
400
+ def with_extension(source, extension)
401
+ name = source.to_s
402
+ name.end_with?(extension) ? name : "#{name}#{extension}"
403
+ end
404
+ end
405
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "sghtmltopdf/version"
4
+ require_relative "sghtmltopdf/options"
5
+ require_relative "sghtmltopdf/configuration"
6
+ require_relative "sghtmltopdf/renderer"
7
+
8
+ # A precompiled gem puts the `.so` in a directory per Ruby minor version
9
+ # (rake-compiler's cross-build convention). During development `rake compile` puts it in
10
+ # `lib/sghtmltopdf/sghtmltopdf.so`, so both are tried.
11
+ begin
12
+ RUBY_VERSION =~ /(\d+\.\d+)/
13
+ require "sghtmltopdf/#{Regexp.last_match(1)}/sghtmltopdf"
14
+ rescue LoadError
15
+ require "sghtmltopdf/sghtmltopdf"
16
+ end
17
+
18
+ # It inherits `Error` (defined by the native extension), so it comes after the extension is loaded.
19
+ require_relative "sghtmltopdf/server_client"
20
+
21
+ module Sghtmltopdf
22
+ # The rough number of bytes handed over per call to a block-taking `render` (local conversion only).
23
+ # Calling the block on every settled page would mean reacquiring the GVL too often, so this
24
+ # much is accumulated first.
25
+ DEFAULT_CHUNK_SIZE = 64 * 1024
26
+
27
+ class << self
28
+ # Convert HTML and return the PDF bytes (an ASCII-8BIT String).
29
+ #
30
+ # Given a block, it calls the block per chunk rather than assembling the whole PDF first
31
+ # (returning nil). It is the hook for streaming into Rack's `response.stream` or into an
32
+ # S3 multipart upload (matching the engine's design, which is unaware of the output sink).
33
+ #
34
+ # Sghtmltopdf.render(html) { |bytes| response.stream.write(bytes) }
35
+ #
36
+ # Both locally and when delegating to a server, it can be written out without waiting for
37
+ # the whole PDF to be assembled (locally page by page as they settle; from a server, the
38
+ # `?stream=1` chunked transfer encoding passed straight through).
39
+ #
40
+ # Only the PDF writing is incremental, though: HTML parsing and layout still happen for
41
+ # the whole document first. The first chunk arrives late in the conversion, and the peak
42
+ # memory is no different from the block-less case. To settle pages while the HTML is being
43
+ # read, use it together with `streaming: true`
44
+ # (which trades constraints for a large reduction in memory).
45
+ #
46
+ # The rough bytes per call can be changed with `chunk_size:` (64KiB by default; local
47
+ # conversion only; a smaller value means reacquiring the GVL more often).
48
+ def render(html, **options, &block)
49
+ options = Options.canonicalize(options)
50
+ client = server_client(options)
51
+ return client.render(html.to_s, server_options(options), &block) if client
52
+ return Native.render(html.to_s, argv_for(options)) if block.nil?
53
+
54
+ Native.render_each(html.to_s, argv_for(options), block, chunk_size(options))
55
+ nil
56
+ end
57
+
58
+ # Convert HTML and write it to `path`.
59
+ #
60
+ # It writes to a temporary file and renames only on success, so a failure part-way through
61
+ # leaves no broken PDF at the destination (the same when delegating to a server).
62
+ def render_to_file(html, path, **options)
63
+ options = Options.canonicalize(options)
64
+ client = server_client(options)
65
+ return client.render_to_file(html.to_s, server_options(options), path.to_s) if client
66
+
67
+ Native.render_to_file(html.to_s, argv_for(options), path.to_s)
68
+ nil
69
+ end
70
+
71
+ # The global default options.
72
+ def configure
73
+ yield config
74
+ config
75
+ end
76
+
77
+ def config
78
+ @config ||= Configuration.new
79
+ end
80
+
81
+ # Mainly for tests. Resets the configuration to empty.
82
+ def reset_config!
83
+ @config = Configuration.new
84
+ end
85
+
86
+ private
87
+
88
+ # Merge in the order global settings, then call-time options, and turn that into argv.
89
+ def argv_for(options)
90
+ Options.to_argv(config.to_h.merge(options))
91
+ end
92
+
93
+ # With a `server_url` it delegates to the server. The timeout merges in the same order.
94
+ def server_client(options)
95
+ merged = config.to_h.merge(options)
96
+ url = merged[:server_url]
97
+ return nil if url.nil? || url.to_s.empty?
98
+
99
+ ServerClient.new(
100
+ url,
101
+ open_timeout: merged[:server_open_timeout],
102
+ read_timeout: merged[:server_read_timeout]
103
+ )
104
+ end
105
+
106
+ # The rough bytes handed to the block per call (local conversion only).
107
+ def chunk_size(options)
108
+ value = config.to_h.merge(options)[:chunk_size]
109
+ value.nil? ? DEFAULT_CHUNK_SIZE : Integer(value)
110
+ end
111
+
112
+ # The options passed to the server. The injected defaults are removed
113
+ # (the Rails-oriented `base_url` and `allow` are defaults for local file resolution, and
114
+ # server mode cannot take them from a request, giving a 400.
115
+ # An explicitly set value is sent as-is and the server decides whether to accept it).
116
+ def server_options(options)
117
+ config.to_h(with_defaults: false).merge(options)
118
+ end
119
+ end
120
+ end
121
+
122
+ require_relative "sghtmltopdf/railtie" if defined?(::Rails::Railtie)
metadata ADDED
@@ -0,0 +1,69 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sghtmltopdf
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.7.0
5
+ platform: x64-mingw-ucrt
6
+ authors:
7
+ - yo_waka
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-11-01 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: |
14
+ Ruby binding for sghtmltopdf, a successor to wkhtmltopdf: an HTML-to-PDF
15
+ rendering engine written in Rust that needs no browser process. The engine
16
+ runs in-process through a native extension and releases the GVL while
17
+ rendering. On Rails it registers a wicked_pdf compatible renderer, so
18
+ `render pdf: "invoice"` works with the keys you already use.
19
+ email:
20
+ - y.wakahara@gmail.com
21
+ executables: []
22
+ extensions: []
23
+ extra_rdoc_files: []
24
+ files:
25
+ - LICENSE
26
+ - README.md
27
+ - lib/sghtmltopdf.rb
28
+ - lib/sghtmltopdf/3.2/sghtmltopdf.so
29
+ - lib/sghtmltopdf/3.3/sghtmltopdf.so
30
+ - lib/sghtmltopdf/3.4/sghtmltopdf.so
31
+ - lib/sghtmltopdf/4.0/sghtmltopdf.so
32
+ - lib/sghtmltopdf/configuration.rb
33
+ - lib/sghtmltopdf/options.rb
34
+ - lib/sghtmltopdf/railtie.rb
35
+ - lib/sghtmltopdf/renderer.rb
36
+ - lib/sghtmltopdf/server_client.rb
37
+ - lib/sghtmltopdf/version.rb
38
+ - lib/sghtmltopdf/view_helpers.rb
39
+ homepage: https://github.com/waka/sghtmltopdf
40
+ licenses:
41
+ - MIT
42
+ metadata:
43
+ source_code_uri: https://github.com/waka/sghtmltopdf
44
+ changelog_uri: https://github.com/waka/sghtmltopdf/blob/main/CHANGELOG.md
45
+ bug_tracker_uri: https://github.com/waka/sghtmltopdf/issues
46
+ rubygems_mfa_required: 'true'
47
+ post_install_message:
48
+ rdoc_options: []
49
+ require_paths:
50
+ - lib
51
+ required_ruby_version: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: '3.2'
56
+ - - "<"
57
+ - !ruby/object:Gem::Version
58
+ version: 4.1.dev
59
+ required_rubygems_version: !ruby/object:Gem::Requirement
60
+ requirements:
61
+ - - ">="
62
+ - !ruby/object:Gem::Version
63
+ version: '0'
64
+ requirements: []
65
+ rubygems_version: 3.5.23
66
+ signing_key:
67
+ specification_version: 4
68
+ summary: HTML to PDF renderer that does not depend on Chromium, WebKit, or Gecko
69
+ test_files: []