sghtmltopdf 0.5.0-x86_64-darwin

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: be743c44f3d6a9529f42786945776b6fec6b0c9e7464a8e527574531dac7c61b
4
+ data.tar.gz: 4e7912ebd09d433bb3210b1f9e29fadc4d976833901f3174ce5b68b11d08d746
5
+ SHA512:
6
+ metadata.gz: eba51c210b84553fca1ab9a5de083d9a51e5ab3858e9b4c39edcf28c60cc2cf916242a0e7f7d7ad983010f9538f58c00f3ef2c0b078bb24c7f68aa1c5c1908b9
7
+ data.tar.gz: ad16642a65b96aeb416b9fdf05eb1e0307c8c61a439c4ceff384abc38963d20724b0acf7cfc5d876f679f0db496bebbb647b40f56811d52de4f35d1c0a7f46d6
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,114 @@
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`, and `x86_64-darwin`.
17
+ There is no build step on those platforms.
18
+
19
+ Elsewhere (Windows) the gem cannot run in-process — 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.
23
+
24
+ ## Usage
25
+
26
+ ```ruby
27
+ pdf = Sghtmltopdf.render("<h1>Invoice</h1>", page_size: "A4", margin_top: "20mm")
28
+ ```
29
+
30
+ Option names are the CLI long options without `--` and with `-` replaced by `_`, so `--page-size A4` becomes `page_size: "A4"`.
31
+ The [option reference](https://waka.github.io/sghtmltopdf/en/usage/cli/reference.html) lists all of them.
32
+
33
+ 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:
34
+
35
+ ```ruby
36
+ Sghtmltopdf.render_to_file(html, "invoice.pdf", page_size: "A4")
37
+
38
+ Sghtmltopdf.render(html) { |bytes| io.write(bytes) }
39
+ ```
40
+
41
+ ## Rails
42
+
43
+ Adding the gem is enough; the Railtie wires everything up, and nothing is loaded when Rails is absent.
44
+
45
+ ```ruby
46
+ # config/initializers/sghtmltopdf.rb
47
+ Sghtmltopdf.configure do |c|
48
+ c.page_size = "A4"
49
+ c.gothic_font = Rails.root.join("vendor/fonts/NotoSansJP-Regular.ttf")
50
+ end
51
+ ```
52
+
53
+ 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:
54
+
55
+ ```ruby
56
+ class InvoicesController < ApplicationController
57
+ def show
58
+ render pdf: "invoice", # filename; ".pdf" is appended
59
+ template: "invoices/show",
60
+ layout: "pdf",
61
+ page_size: "A4", margin_top: "20mm"
62
+ end
63
+ end
64
+ ```
65
+
66
+ 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.
67
+ 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.
68
+
69
+ ### Assets
70
+
71
+ 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`.
72
+ 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:
73
+
74
+ ```erb
75
+ <%= sghtmltopdf_stylesheet_link_tag "pdf" %>
76
+ <%= sghtmltopdf_image_tag "logo.png" %>
77
+ ```
78
+
79
+ `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`.
80
+ 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`.
81
+
82
+ 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.
83
+
84
+ ### Streaming the response
85
+
86
+ 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:
87
+
88
+ ```ruby
89
+ class InvoicesController < ApplicationController
90
+ include ActionController::Live
91
+
92
+ def show
93
+ response.headers["Content-Type"] = "application/pdf"
94
+ html = render_to_string(template: "invoices/show", layout: "pdf")
95
+ Sghtmltopdf.render(html) { |bytes| response.stream.write(bytes) }
96
+ ensure
97
+ response.stream.close
98
+ end
99
+ end
100
+ ```
101
+
102
+ ## Delegating to a server
103
+
104
+ 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.
105
+
106
+ ```ruby
107
+ Sghtmltopdf.configure { |c| c.server_url = "http://pdf:8080" }
108
+ ```
109
+
110
+ The [official Docker image](https://waka.github.io/sghtmltopdf/en/getting-started/docker.html) runs that server and bundles Japanese fonts.
111
+
112
+ ## License
113
+
114
+ MIT License ([LICENSE](LICENSE)).
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sghtmltopdf
4
+ # グローバルな既定オプション。
5
+ #
6
+ # Sghtmltopdf.configure do |c|
7
+ # c.page_size = "A4"
8
+ # c.gothic_font = "/path/to/NotoSansJP-Regular.ttf"
9
+ # end
10
+ #
11
+ # ここで設定した値は`render`/`render_to_file`の引数で上書きできる
12
+ # (マージ順はグローバル → 呼び出し時)。
13
+ #
14
+ # キー名の妥当性は検査しない。オプション定義はRust側(`cli/options.rs`)の
15
+ # 1箇所に集約する方針のため、未知のキーはレンダリング時にclapが`UsageError`をraiseする。
16
+ class Configuration
17
+ def initialize(options = {})
18
+ @options = {}
19
+ # 明示的に設定した値(@options)と、Railtieなどが流し込んだ既定値
20
+ # (@defaults)は分けて持つ。読み出しは常に@optionsが勝つので、
21
+ # イニシャライザの実行順に依存しない。
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] 流し込まれた既定値を含めるか。
36
+ # HTTPサーバへ委譲するときは`false`にする。Rails向けの既定値
37
+ # (`base_url`・`allow`)はローカルのファイル解決のためのもので、
38
+ # サーバモードではリクエストから指定できないキーだから
39
+ def to_h(with_defaults: true)
40
+ with_defaults ? @defaults.merge(@options) : @options.dup
41
+ end
42
+
43
+ # 既定値を流し込む。Railtieが Rails向けの既定値を入れるのに使う。
44
+ # 明示的に設定された値より弱い(順序に関係なく`[]=`が勝つ)。
45
+ def apply_defaults(defaults)
46
+ defaults.each { |key, value| @defaults[Options.canonical_key(key)] = value }
47
+ self
48
+ end
49
+
50
+ # `c.page_size = "A4"`と`c.page_size`を受ける。
51
+ def method_missing(name, *args)
52
+ key = name.to_s
53
+ if key.end_with?("=")
54
+ raise ArgumentError, "#{name}は引数1つを取ります" unless args.size == 1
55
+
56
+ self[key.chomp("=")] = args.first
57
+ else
58
+ raise ArgumentError, "#{name}は引数を取りません" 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,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Sghtmltopdf
6
+ # オプションハッシュを変換する。
7
+ #
8
+ # * ネイティブ拡張へ渡すCLIの引数列(argv) … [.to_argv]
9
+ # * HTTPサーバモードへ渡すクエリ文字列 … [.to_query]
10
+ module Options
11
+ # 入力は常に標準入力を表す`-`を置く(実際のバイト列はFFIで直接渡すため
12
+ # 読まれない)。出力先はRust側のSinkが決めるので、ここもダミーの`-`。
13
+ # `-`入力のときCLIは`--output`を必須にするため、省略はできない。
14
+ ARGV_PREFIX = ["sghtmltopdf", "-", "--output", "-"].freeze
15
+
16
+ # Ruby側だけで解釈するキー。変換オプションではないので、argvにも
17
+ # クエリにも出さない。
18
+ TRANSPORT_KEYS = %i[server_url server_open_timeout server_read_timeout chunk_size].freeze
19
+
20
+ # 別名のキー(値は正規名)。CLIは`--allow`を`--allow-path`の別名として
21
+ # 受けるが、Ruby側は2つのキーのまま持ち回ってはいけない。既定が一方の
22
+ # キー、呼び出し時の指定がもう一方のキーだと、ハッシュのマージでは
23
+ # 上書きにならず両方がargvへ出てしまう(同じフラグの繰り返しは
24
+ # 「置き換え」ではなく「合併」の意味になる)。
25
+ ALIAS_KEYS = {allow: :allow_path}.freeze
26
+
27
+ module_function
28
+
29
+ # 別名のキーを正規名へ寄せる。
30
+ def canonical_key(key)
31
+ key = key.to_sym
32
+ ALIAS_KEYS.fetch(key, key)
33
+ end
34
+
35
+ # ハッシュのキーをまとめて正規化する。
36
+ def canonicalize(options)
37
+ options.to_h { |key, value| [canonical_key(key), value] }
38
+ end
39
+
40
+ # @param options [Hash] Rubyのオプションハッシュ
41
+ # @return [Array<String>] clapへ渡す引数列
42
+ def to_argv(options)
43
+ argv = ARGV_PREFIX.dup
44
+ each_pair(options) do |name, value|
45
+ argv.push("--#{name}")
46
+ argv.push(value) unless value.nil?
47
+ end
48
+ argv
49
+ end
50
+
51
+ # @param options [Hash] Rubyのオプションハッシュ
52
+ # @return [String] `POST /pdf`のクエリ文字列(先頭に`?`は付けない)
53
+ def to_query(options)
54
+ parts = []
55
+ each_pair(options) do |name, value|
56
+ # 値なしのフラグはキーだけを置く(サーバは値なし=真として扱う)。
57
+ parts << (value.nil? ? escape(name) : "#{escape(name)}=#{escape(value)}")
58
+ end
59
+ parts.join("&")
60
+ end
61
+
62
+ # 1つのキーと値をargvの断片へ変換する。
63
+ #
64
+ # page_size: "A4" → ["--page-size", "A4"]
65
+ # grayscale: true → ["--grayscale"]
66
+ # grayscale: false → []
67
+ # allow: ["/a", "/b"] → ["--allow", "/a", "--allow", "/b"]
68
+ def args_for(key, value)
69
+ pairs_for(key, value).flat_map { |name, arg| arg.nil? ? ["--#{name}"] : ["--#{name}", arg] }
70
+ end
71
+
72
+ # 1つのキーと値を「フラグ名と値」のペアの列にする。値が`nil`のペアは
73
+ # 値を取らないフラグ(`--toc`など)。
74
+ def pairs_for(key, value)
75
+ name = flag_name(key)
76
+ return font_pairs(value) if name == "font"
77
+
78
+ case value
79
+ when nil, false then []
80
+ when true then [[name, nil]]
81
+ # 配列は同じオプションの繰り返し。要素ごとに同じ規則を適用する。
82
+ when Array then value.flat_map { |element| pairs_for(key, element) }
83
+ when Hash
84
+ # wicked_pdfの`margin: {top: 10}`のような入れ子は受けない。対応する
85
+ # CLIフラグが無く、機械的に平坦化すると綴り違いのキーまで黙って
86
+ # 通ってしまう。移行時は移行ガイドの対応表を見て書き換えてもらう。
87
+ # なお単位を省いた数値の解釈はwicked_pdfと同じくmm(`cli/units.rs`)。
88
+ example = value.keys.first
89
+ raise ArgumentError,
90
+ "#{key}にHashは渡せません(pathとindexを取るのは:fontだけです)。" \
91
+ "入れ子のオプションは平坦なキーで指定してください" \
92
+ "#{": 例 #{key}_#{example}: \"…\"" if example}"
93
+ else [[name, value.to_s]]
94
+ end
95
+ end
96
+
97
+ # `--font`と`--font-index`は出現順で対応付けられる(CLIは
98
+ # `ArgMatches#indices_of`で「`--font-index`より手前にある最後の`--font`」
99
+ # へ結び付ける)。そのため、フェイス番号は
100
+ # 必ず対応する`--font`の直後へ置く。
101
+ #
102
+ # font: "a.ttf" → ["--font", "a.ttf"]
103
+ # font: {path: "a.ttc", index: 1} → ["--font", "a.ttc", "--font-index", "1"]
104
+ # font: ["a.ttf", {path: "b.ttc", index: 2}]
105
+ # → ["--font", "a.ttf", "--font", "b.ttc", "--font-index", "2"]
106
+ def font_args(value)
107
+ font_pairs(value).flat_map { |name, arg| ["--#{name}", arg] }
108
+ end
109
+
110
+ def font_pairs(value)
111
+ case value
112
+ when nil, false then []
113
+ when Array then value.flat_map { |element| font_pairs(element) }
114
+ when Hash
115
+ path = value[:path] || value["path"]
116
+ raise ArgumentError, "fontのHashにはpathが必要です: #{value.inspect}" if path.nil?
117
+
118
+ index = value[:index] || value["index"]
119
+ pairs = [["font", path.to_s]]
120
+ pairs << ["font-index", index.to_s] unless index.nil?
121
+ pairs
122
+ else [["font", value.to_s]]
123
+ end
124
+ end
125
+
126
+ # `:page_size` → `page-size`。
127
+ def flag_name(key)
128
+ key.to_s.tr("_", "-")
129
+ end
130
+
131
+ # 変換オプションだけを、渡された順にペアとして列挙する。
132
+ def each_pair(options, &block)
133
+ options.each do |key, value|
134
+ key = canonical_key(key)
135
+ next if TRANSPORT_KEYS.include?(key)
136
+
137
+ pairs_for(key, value).each(&block)
138
+ end
139
+ end
140
+
141
+ def escape(value)
142
+ URI.encode_www_form_component(value.to_s)
143
+ end
144
+ end
145
+ 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
+ # 読むのはinitializerの中ではなく`after_initialize`。パイプラインが
32
+ # `config.assets.paths`を埋めるのは自分のinitializer(Propshaftなら
33
+ # `propshaft.append_assets_path`)で、そちらの方が後に走るため。
34
+ #
35
+ # `config/initializers`より後になるが、`apply_defaults`は明示的に設定した
36
+ # 値より常に弱いので、ユーザーの設定を踏むことはない。
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,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sghtmltopdf
4
+ # `render pdf: "invoice"`のオプションを、
5
+ #
6
+ # * Railsのビュー描画(`render_to_string`)へ渡すもの
7
+ # * レスポンスの組み立て(`send_data`)へ渡すもの
8
+ # * PDF変換(`Sghtmltopdf.render`)へ渡すもの
9
+ #
10
+ # の3つに振り分ける。
11
+ # Railsに依存しないpure Rubyのクラスなので、Rails無しでも単体テストできる。
12
+ class Renderer
13
+ # `render_to_string`へそのまま渡すキー。
14
+ RAILS_RENDER_KEYS = %i[
15
+ action assigns body collection file formats handlers html inline layout
16
+ locals object partial plain prefixes template variants
17
+ ].freeze
18
+
19
+ # レスポンスの組み立てに使うキー(`send_data`へ渡す)。
20
+ RESPONSE_KEYS = %i[disposition filename status].freeze
21
+
22
+ # レンダラ自身が解釈するキー(PDFにせずHTMLのまま返すデバッグ用)。
23
+ RENDERER_KEYS = %i[show_as_html].freeze
24
+
25
+ PDF_CONTENT_TYPE = "application/pdf"
26
+ HTML_CONTENT_TYPE = "text/html"
27
+
28
+ attr_reader :name, :options
29
+
30
+ # @param name [String, Symbol, nil] `pdf:`に渡された値(ファイル名の素)
31
+ # @param options [Hash] `render`に渡されたその他のオプション
32
+ # @param default_name [String, nil] `name`が空のときのファイル名
33
+ # (コントローラの`action_name`を想定)
34
+ def initialize(name, options = {}, default_name: nil)
35
+ @name = blank?(name) ? (default_name || "document").to_s : name.to_s
36
+ @options = options.to_h { |key, value| [key.to_sym, value] }
37
+ end
38
+
39
+ # `ActionController::Renderers.add(:pdf)`でレンダラを登録する。
40
+ # RailtieのAction Controller読み込みフック(`on_load`)から呼ぶ。
41
+ def self.register!
42
+ ::ActionController::Renderers.add(:pdf) do |name, options|
43
+ renderer = ::Sghtmltopdf::Renderer.new(name, options, default_name: action_name)
44
+ html = render_to_string(**renderer.render_options)
45
+ send_data(renderer.body_for(html), **renderer.send_data_options)
46
+ end
47
+ end
48
+
49
+ # ビューの描画に使うオプション。
50
+ def render_options
51
+ options.select { |key, _| RAILS_RENDER_KEYS.include?(key) }
52
+ end
53
+
54
+ # PDF変換に使うオプション。
55
+ def convert_options
56
+ known = RAILS_RENDER_KEYS + RESPONSE_KEYS + RENDERER_KEYS
57
+ options.reject { |key, _| known.include?(key) }
58
+ end
59
+
60
+ # 描画したHTMLをレスポンスの本文へ変換する。
61
+ def body_for(html)
62
+ show_as_html? ? html : Sghtmltopdf.render(html, **convert_options)
63
+ end
64
+
65
+ def send_data_options
66
+ opts = {type: content_type, disposition: disposition}
67
+ opts[:filename] = filename unless show_as_html?
68
+ opts[:status] = options[:status] if options.key?(:status)
69
+ opts
70
+ end
71
+
72
+ def content_type
73
+ show_as_html? ? HTML_CONTENT_TYPE : PDF_CONTENT_TYPE
74
+ end
75
+
76
+ # `filename: "x.pdf"` > `pdf: "x"` の順。拡張子は二重に付けない。
77
+ def filename
78
+ base = blank?(options[:filename]) ? name : options[:filename].to_s
79
+ base.downcase.end_with?(".pdf") ? base : "#{base}.pdf"
80
+ end
81
+
82
+ # wicked_pdfと同じく既定は`inline`(ブラウザ内で開く)。
83
+ def disposition
84
+ blank?(options[:disposition]) ? "inline" : options[:disposition].to_s
85
+ end
86
+
87
+ # wicked_pdfの`show_as_html`相当。PDFにせずHTMLをそのまま返すので、
88
+ # ブラウザの開発者ツールでレイアウトを確認できる。
89
+ def show_as_html?
90
+ value = options[:show_as_html]
91
+ !(value.nil? || value == false || value == "false")
92
+ end
93
+
94
+ private
95
+
96
+ def blank?(value)
97
+ value.nil? || (value.respond_to?(:empty?) && value.empty?)
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+
6
+ module Sghtmltopdf
7
+ class ServerError < Error; end
8
+
9
+ # HTTPサーバモード(`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
+ # 一度に読むチャンクの目安。`?stream=1`のときはこの単位でブロックへ渡る。
18
+ CHUNK_SIZE = 64 * 1024
19
+
20
+ attr_reader :uri, :open_timeout, :read_timeout
21
+
22
+ # @param url [String] サーバのベース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
+ # HTMLをPDFへ変換する。
30
+ #
31
+ # ブロックを渡すと`?stream=1`(chunked transfer encoding)を使い、
32
+ # サーバがページを確定したそばからチャンクを渡す。ブロックが無ければ
33
+ # PDF全体をStringで返す。
34
+ def render(html, options, &block)
35
+ request = build_request(html, options, stream: !block.nil?)
36
+ pdf = nil
37
+ start do |http|
38
+ # `request`はブロック付きだとレスポンスオブジェクトを返すので、
39
+ # 結果は外の変数で受ける。
40
+ http.request(request) do |response|
41
+ ensure_success!(response)
42
+ if block
43
+ response.read_body { |chunk| block.call(chunk.b) }
44
+ else
45
+ pdf = read_all(response)
46
+ end
47
+ end
48
+ end
49
+ pdf
50
+ end
51
+
52
+ # 変換結果を`path`へ書き出す。途中で失敗しても壊れたPDFを残さないよう、
53
+ # 一時ファイルへ書いてからrenameする(ネイティブ拡張の`FileSink`と同じ
54
+ # 挙動に揃えている)。
55
+ def render_to_file(html, options, path)
56
+ tmp = "#{path}.#{Process.pid}.tmp"
57
+ begin
58
+ File.open(tmp, "wb") do |file|
59
+ render(html, options) { |chunk| file.write(chunk) }
60
+ end
61
+ rescue SystemCallError => e
62
+ File.unlink(tmp) if File.exist?(tmp)
63
+ raise InputError, "#{path}への書き出しに失敗しました: #{e.message}"
64
+ rescue StandardError
65
+ File.unlink(tmp) if File.exist?(tmp)
66
+ raise
67
+ end
68
+ File.rename(tmp, path)
69
+ nil
70
+ end
71
+
72
+ private
73
+
74
+ def parse(url)
75
+ uri = URI.parse(url.to_s)
76
+ unless uri.is_a?(URI::HTTP) && uri.host
77
+ raise ArgumentError, "server_urlにはhttp(s)のURLを指定してください: #{url.inspect}"
78
+ end
79
+
80
+ uri
81
+ end
82
+
83
+ def build_request(html, options, stream:)
84
+ query = Options.to_query(options)
85
+ query = stream ? [query, "stream=1"].reject(&:empty?).join("&") : query
86
+ target = uri.dup
87
+ target.path = "/pdf"
88
+ target.query = query.empty? ? nil : query
89
+
90
+ request = Net::HTTP::Post.new(target)
91
+ request["Content-Type"] = "text/html; charset=utf-8"
92
+ request.body = html.to_s.b
93
+ request
94
+ end
95
+
96
+ def start(&block)
97
+ Net::HTTP.start(
98
+ uri.host, uri.port,
99
+ use_ssl: uri.scheme == "https",
100
+ open_timeout: open_timeout,
101
+ read_timeout: read_timeout,
102
+ &block
103
+ )
104
+ rescue Net::OpenTimeout, Net::ReadTimeout => e
105
+ raise ServerError, "#{base}への接続がタイムアウトしました: #{e.class}"
106
+ rescue SocketError, SystemCallError, IOError, OpenSSL::SSL::SSLError => e
107
+ raise ServerError, "#{base}への接続に失敗しました: #{e.message}"
108
+ end
109
+
110
+ # エラー応答の本文は`text/plain`の日本語メッセージ(CLIと同じ文言)。
111
+ def ensure_success!(response)
112
+ return if response.is_a?(Net::HTTPOK)
113
+
114
+ message = read_all(response).force_encoding(Encoding::UTF_8).strip
115
+ raise error_class(response), "#{base}: #{message}"
116
+ end
117
+
118
+ def error_class(response)
119
+ case response.code.to_i
120
+ when 400 then UsageError
121
+ when 413 then InputError
122
+ when 500 then RenderError
123
+ # 404/405はパスやメソッドの間違い=相手がsghtmltopdfのサーバでない
124
+ # 可能性が高い。503/504はキュー溢れ・キュー待ちのタイムアウト。
125
+ else ServerError
126
+ end
127
+ end
128
+
129
+ def read_all(response)
130
+ buffer = +""
131
+ response.read_body { |chunk| buffer << chunk }
132
+ buffer.b
133
+ end
134
+
135
+ def base
136
+ "#{uri.scheme}://#{uri.host}:#{uri.port}"
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sghtmltopdf
4
+ VERSION = "0.5.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,123 @@
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
+ # precompiled gemはRubyのマイナーバージョンごとのディレクトリへ`.so`を置く
9
+ # (rake-compilerのクロスビルドの慣習)。開発中の`rake compile`は
10
+ # `lib/sghtmltopdf/sghtmltopdf.so`に置くので、両方を試す。
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
+ # `Error`(ネイティブ拡張が定義)を継承するため、拡張の読み込みより後に書く。
19
+ require_relative "sghtmltopdf/server_client"
20
+
21
+ module Sghtmltopdf
22
+ # ブロック付き`render`で1回に渡すバイト数の目安(ローカル変換のみ)。
23
+ # ページ確定ごとにブロックを呼ぶとGVLの取り直しが増えるため、ここまで
24
+ # 溜めてから渡す。
25
+ DEFAULT_CHUNK_SIZE = 64 * 1024
26
+
27
+ class << self
28
+ # HTMLを変換してPDFのバイト列(ASCII-8BITのString)を返す。
29
+ #
30
+ # ブロックを渡すと、PDF全体を組み立ててから返す代わりにチャンクごとに
31
+ # ブロックを呼ぶ(返り値はnil)。Rackの`response.stream`へ流したり、S3の
32
+ # マルチパートアップロードへ繋いだりするための口(エンジン側は出力先(sink)
33
+ # を意識しない設計に対応する)。
34
+ #
35
+ # Sghtmltopdf.render(html) { |bytes| response.stream.write(bytes) }
36
+ #
37
+ # ローカル・サーバ委譲のどちらでも、PDF全体が組み上がるのを待たずに
38
+ # 書き出せる(ローカルは確定したページから順に、サーバは`?stream=1`の
39
+ # chunked transfer encodingをそのまま渡す)。
40
+ #
41
+ # ただし逐次になるのはPDFの書き出しだけで、HTMLのパースとレイアウトは
42
+ # 文書全体に対して先に行う。最初のチャンクが届くのは変換の終盤で、
43
+ # ピークメモリもブロック無しの場合と変わらない。HTMLを読みながら
44
+ # ページを確定させたい場合は`streaming: true`と併せて使う
45
+ # (制約と引き換えにメモリが大きく減る)。
46
+ #
47
+ # 1回に渡すバイト数の目安は`chunk_size:`で変えられる(既定64KiB。
48
+ # ローカル変換のみ。小さくするとGVLの取り直しが増える)。
49
+ def render(html, **options, &block)
50
+ options = Options.canonicalize(options)
51
+ client = server_client(options)
52
+ return client.render(html.to_s, server_options(options), &block) if client
53
+ return Native.render(html.to_s, argv_for(options)) if block.nil?
54
+
55
+ Native.render_each(html.to_s, argv_for(options), block, chunk_size(options))
56
+ nil
57
+ end
58
+
59
+ # HTMLを変換して`path`へ書き出す。
60
+ #
61
+ # 一時ファイルへ書いて成功時だけrenameするため、途中で失敗しても
62
+ # 壊れたPDFが出力先に残らない(サーバへ委譲する場合も同じ)。
63
+ def render_to_file(html, path, **options)
64
+ options = Options.canonicalize(options)
65
+ client = server_client(options)
66
+ return client.render_to_file(html.to_s, server_options(options), path.to_s) if client
67
+
68
+ Native.render_to_file(html.to_s, argv_for(options), path.to_s)
69
+ nil
70
+ end
71
+
72
+ # グローバルな既定オプション。
73
+ def configure
74
+ yield config
75
+ config
76
+ end
77
+
78
+ def config
79
+ @config ||= Configuration.new
80
+ end
81
+
82
+ # 主にテスト用。設定を空に戻す。
83
+ def reset_config!
84
+ @config = Configuration.new
85
+ end
86
+
87
+ private
88
+
89
+ # グローバル設定 → 呼び出し時オプションの順にマージしてargvにする。
90
+ def argv_for(options)
91
+ Options.to_argv(config.to_h.merge(options))
92
+ end
93
+
94
+ # `server_url`があればサーバへ委譲する。タイムアウトも同じ順でマージする。
95
+ def server_client(options)
96
+ merged = config.to_h.merge(options)
97
+ url = merged[:server_url]
98
+ return nil if url.nil? || url.to_s.empty?
99
+
100
+ ServerClient.new(
101
+ url,
102
+ open_timeout: merged[:server_open_timeout],
103
+ read_timeout: merged[:server_read_timeout]
104
+ )
105
+ end
106
+
107
+ # ブロックへ1回に渡すバイト数の目安(ローカル変換のみ)。
108
+ def chunk_size(options)
109
+ value = config.to_h.merge(options)[:chunk_size]
110
+ value.nil? ? DEFAULT_CHUNK_SIZE : Integer(value)
111
+ end
112
+
113
+ # サーバへ渡すオプション。流し込まれた既定値は外す
114
+ # (Rails向けの`base_url`・`allow`はローカルのファイル解決のための
115
+ # 既定値で、サーバモードではリクエストから指定できず400になる。
116
+ # 明示的に設定した値はそのまま送り、可否はサーバに判断させる)。
117
+ def server_options(options)
118
+ config.to_h(with_defaults: false).merge(options)
119
+ end
120
+ end
121
+ end
122
+
123
+ 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.5.0
5
+ platform: x86_64-darwin
6
+ authors:
7
+ - yo_waka
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-09-20 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.bundle
29
+ - lib/sghtmltopdf/3.3/sghtmltopdf.bundle
30
+ - lib/sghtmltopdf/3.4/sghtmltopdf.bundle
31
+ - lib/sghtmltopdf/4.0/sghtmltopdf.bundle
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: []