sghtmltopdf 0.1.0-aarch64-linux

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: 76c5e09cd07d909ef74f7b5c2c56ea163f16dd407e2d309238ac0932309e9528
4
+ data.tar.gz: 56498f8925b1e32b11d919b77620886cf7c39287d55d3fdeb6547e84e9bf35a6
5
+ SHA512:
6
+ metadata.gz: cc27feea209ad21fc3af19e4d3e0aac98039bf004ef6a21f42f18b8888af56a9390a491ebfc1a8e45482b545a9f3e7c258b5216ee162c9f7df4248276c6fd6d3
7
+ data.tar.gz: cc1033fec212e7a14c8a6f66da6f0d6cb2d257a1fd27d13d60b1bb6f59039ba73e211ce2db6b913a633dc9472f1f58e2a9c624785aacd5e5178323ee1a771154
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,109 @@
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`, and `arm64-darwin`.
17
+ There is no build step on those platforms.
18
+
19
+ Elsewhere (Intel Mac, 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 `Rails.root` via `allow`.
72
+ That is enough for a precompiled production app; in development, these helpers inline the asset instead:
73
+
74
+ ```erb
75
+ <%= sghtmltopdf_stylesheet_link_tag "pdf" %>
76
+ <%= sghtmltopdf_image_tag "logo.png" %>
77
+ ```
78
+
79
+ ### Streaming the response
80
+
81
+ 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:
82
+
83
+ ```ruby
84
+ class InvoicesController < ApplicationController
85
+ include ActionController::Live
86
+
87
+ def show
88
+ response.headers["Content-Type"] = "application/pdf"
89
+ html = render_to_string(template: "invoices/show", layout: "pdf")
90
+ Sghtmltopdf.render(html) { |bytes| response.stream.write(bytes) }
91
+ ensure
92
+ response.stream.close
93
+ end
94
+ end
95
+ ```
96
+
97
+ ## Delegating to a server
98
+
99
+ 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.
100
+
101
+ ```ruby
102
+ Sghtmltopdf.configure { |c| c.server_url = "http://pdf:8080" }
103
+ ```
104
+
105
+ The [official Docker image](https://waka.github.io/sghtmltopdf/en/getting-started/docker.html) runs that server and bundles Japanese fonts.
106
+
107
+ ## License
108
+
109
+ MIT License ([LICENSE](LICENSE)).
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -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 = key.to_sym
28
+ @options.key?(key) ? @options[key] : @defaults[key]
29
+ end
30
+
31
+ def []=(key, value)
32
+ @options[key.to_sym] = 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[key.to_sym] = 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,126 @@
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
+ module_function
21
+
22
+ # @param options [Hash] Rubyのオプションハッシュ
23
+ # @return [Array<String>] clapへ渡す引数列
24
+ def to_argv(options)
25
+ argv = ARGV_PREFIX.dup
26
+ each_pair(options) do |name, value|
27
+ argv.push("--#{name}")
28
+ argv.push(value) unless value.nil?
29
+ end
30
+ argv
31
+ end
32
+
33
+ # @param options [Hash] Rubyのオプションハッシュ
34
+ # @return [String] `POST /pdf`のクエリ文字列(先頭に`?`は付けない)
35
+ def to_query(options)
36
+ parts = []
37
+ each_pair(options) do |name, value|
38
+ # 値なしのフラグはキーだけを置く(サーバは値なし=真として扱う)。
39
+ parts << (value.nil? ? escape(name) : "#{escape(name)}=#{escape(value)}")
40
+ end
41
+ parts.join("&")
42
+ end
43
+
44
+ # 1つのキーと値をargvの断片へ変換する。
45
+ #
46
+ # page_size: "A4" → ["--page-size", "A4"]
47
+ # grayscale: true → ["--grayscale"]
48
+ # grayscale: false → []
49
+ # allow: ["/a", "/b"] → ["--allow", "/a", "--allow", "/b"]
50
+ def args_for(key, value)
51
+ pairs_for(key, value).flat_map { |name, arg| arg.nil? ? ["--#{name}"] : ["--#{name}", arg] }
52
+ end
53
+
54
+ # 1つのキーと値を「フラグ名と値」のペアの列にする。値が`nil`のペアは
55
+ # 値を取らないフラグ(`--toc`など)。
56
+ def pairs_for(key, value)
57
+ name = flag_name(key)
58
+ return font_pairs(value) if name == "font"
59
+
60
+ case value
61
+ when nil, false then []
62
+ when true then [[name, nil]]
63
+ # 配列は同じオプションの繰り返し。要素ごとに同じ規則を適用する。
64
+ when Array then value.flat_map { |element| pairs_for(key, element) }
65
+ when Hash
66
+ # wicked_pdfの`margin: {top: 10}`のような入れ子は受けない。数値の
67
+ # 単位の解釈が違う(wicked_pdfはmm・こちらはpx)ため、機械的に
68
+ # 平坦化すると黙って別の余白になる。移行時は
69
+ # 移行ガイドの対応表を見て書き換えてもらう。
70
+ example = value.keys.first
71
+ raise ArgumentError,
72
+ "#{key}にHashは渡せません(pathとindexを取るのは:fontだけです)。" \
73
+ "入れ子のオプションは平坦なキーで指定してください" \
74
+ "#{": 例 #{key}_#{example}: \"…\"" if example}"
75
+ else [[name, value.to_s]]
76
+ end
77
+ end
78
+
79
+ # `--font`と`--font-index`は出現順で対応付けられる(CLIは
80
+ # `ArgMatches#indices_of`で「`--font-index`より手前にある最後の`--font`」
81
+ # へ結び付ける)。そのため、フェイス番号は
82
+ # 必ず対応する`--font`の直後へ置く。
83
+ #
84
+ # font: "a.ttf" → ["--font", "a.ttf"]
85
+ # font: {path: "a.ttc", index: 1} → ["--font", "a.ttc", "--font-index", "1"]
86
+ # font: ["a.ttf", {path: "b.ttc", index: 2}]
87
+ # → ["--font", "a.ttf", "--font", "b.ttc", "--font-index", "2"]
88
+ def font_args(value)
89
+ font_pairs(value).flat_map { |name, arg| ["--#{name}", arg] }
90
+ end
91
+
92
+ def font_pairs(value)
93
+ case value
94
+ when nil, false then []
95
+ when Array then value.flat_map { |element| font_pairs(element) }
96
+ when Hash
97
+ path = value[:path] || value["path"]
98
+ raise ArgumentError, "fontのHashにはpathが必要です: #{value.inspect}" if path.nil?
99
+
100
+ index = value[:index] || value["index"]
101
+ pairs = [["font", path.to_s]]
102
+ pairs << ["font-index", index.to_s] unless index.nil?
103
+ pairs
104
+ else [["font", value.to_s]]
105
+ end
106
+ end
107
+
108
+ # `:page_size` → `page-size`。
109
+ def flag_name(key)
110
+ key.to_s.tr("_", "-")
111
+ end
112
+
113
+ # 変換オプションだけを、渡された順にペアとして列挙する。
114
+ def each_pair(options, &block)
115
+ options.each do |key, value|
116
+ next if TRANSPORT_KEYS.include?(key.to_sym)
117
+
118
+ pairs_for(key, value).each(&block)
119
+ end
120
+ end
121
+
122
+ def escape(value)
123
+ URI.encode_www_form_component(value.to_s)
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,31 @@
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
+ def self.default_options(root)
10
+ root = root.to_s
11
+ defaults = {allow: [root]}
12
+ public_dir = File.join(root, "public")
13
+ defaults[:base_url] = public_dir if File.directory?(public_dir)
14
+ defaults
15
+ end
16
+
17
+ initializer "sghtmltopdf.defaults" do |app|
18
+ Sghtmltopdf.config.apply_defaults(Sghtmltopdf::Railtie.default_options(app.root))
19
+ end
20
+
21
+ initializer "sghtmltopdf.renderer" do
22
+ ActiveSupport.on_load(:action_controller) do
23
+ Sghtmltopdf::Renderer.register!
24
+ end
25
+
26
+ ActiveSupport.on_load(:action_view) do
27
+ include Sghtmltopdf::ViewHelpers
28
+ end
29
+ end
30
+ end
31
+ 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.1.0"
5
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sghtmltopdf
4
+ # Action View用のヘルパ。
5
+ #
6
+ # PDFのレンダリングはHTTPサーバを介さないため、`/assets/…`のようなURLは
7
+ # ローカルファイルとして解決される(`--base-url`の既定は
8
+ # `Rails.root/public`。[Railtie.default_options])。precompile済みの
9
+ # 本番環境ではこれで素の`stylesheet_link_tag`もそのまま動くが、開発環境の
10
+ # ようにアセットがまだ`public/`へ書き出されていない場合は解決できない。
11
+ #
12
+ # そこで、
13
+ #
14
+ # <%= sghtmltopdf_stylesheet_link_tag "pdf" %>
15
+ #
16
+ # のようにCSSの中身を`<style>`へ展開するヘルパを用意する(wicked_pdfの
17
+ # `wicked_pdf_stylesheet_link_tag`に相当)。
18
+ module ViewHelpers
19
+ # アセットのローカルファイルパスを返す。見つからなければ`nil`。
20
+ #
21
+ # 1. `public/`配下(precompile済み。本番環境)
22
+ # 2. アセットパイプラインのロードパス(開発環境。Propshaft/Sprockets)
23
+ #
24
+ # の順に探す。パイプラインの参照はどちらのgemにも依存しないよう
25
+ # `respond_to?`で分岐している(best effort)。
26
+ def sghtmltopdf_asset_path(source)
27
+ path = source.to_s
28
+ return path if path.start_with?("/") && File.file?(path)
29
+
30
+ from_public_dir(path) || from_asset_pipeline(path)
31
+ end
32
+
33
+ # CSSの中身を`<style>`へ展開する。複数指定でき、見つからないものは
34
+ # 黙って飛ばす(PDF生成そのものは止めない)。
35
+ def sghtmltopdf_stylesheet_link_tag(*sources)
36
+ css = sources.flatten.filter_map do |source|
37
+ path = sghtmltopdf_asset_path(with_extension(source, ".css"))
38
+ File.read(path) if path
39
+ end
40
+ return "".html_safe if css.empty?
41
+
42
+ content_tag(:style, css.join("\n").html_safe, type: "text/css")
43
+ end
44
+
45
+ # `image_tag`のsrcをローカルファイルパスへ差し替える。
46
+ def sghtmltopdf_image_tag(source, options = {})
47
+ image_tag(sghtmltopdf_asset_path(source) || source, options)
48
+ end
49
+
50
+ private
51
+
52
+ # `asset_path`が返すURL(asset_hostが付くこともある)からパス部分だけを
53
+ # 取り出し、`public/`配下の実ファイルへ対応付ける。
54
+ def from_public_dir(source)
55
+ url = respond_to?(:asset_path) ? asset_path(source) : source
56
+ relative = url.to_s.sub(%r{\Ahttps?://[^/]+}, "").split(/[?#]/).first.to_s
57
+ return nil if relative.empty?
58
+
59
+ candidate = File.join(::Rails.public_path.to_s, relative)
60
+ File.file?(candidate) ? candidate : nil
61
+ end
62
+
63
+ def from_asset_pipeline(source)
64
+ assets = ::Rails.application.try(:assets)
65
+ return nil if assets.nil?
66
+
67
+ # Propshaft
68
+ if assets.respond_to?(:load_path)
69
+ found = assets.load_path.find(source)
70
+ return found.path.to_s if found.respond_to?(:path)
71
+ end
72
+ # Sprockets
73
+ if assets.respond_to?(:[])
74
+ found = assets[source]
75
+ return found.filename.to_s if found.respond_to?(:filename)
76
+ end
77
+ nil
78
+ end
79
+
80
+ def with_extension(source, extension)
81
+ name = source.to_s
82
+ name.end_with?(extension) ? name : "#{name}#{extension}"
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,121 @@
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
+ 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
+ # HTMLを変換して`path`へ書き出す。
59
+ #
60
+ # 一時ファイルへ書いて成功時だけrenameするため、途中で失敗しても
61
+ # 壊れたPDFが出力先に残らない(サーバへ委譲する場合も同じ)。
62
+ def render_to_file(html, path, **options)
63
+ client = server_client(options)
64
+ return client.render_to_file(html.to_s, server_options(options), path.to_s) if client
65
+
66
+ Native.render_to_file(html.to_s, argv_for(options), path.to_s)
67
+ nil
68
+ end
69
+
70
+ # グローバルな既定オプション。
71
+ def configure
72
+ yield config
73
+ config
74
+ end
75
+
76
+ def config
77
+ @config ||= Configuration.new
78
+ end
79
+
80
+ # 主にテスト用。設定を空に戻す。
81
+ def reset_config!
82
+ @config = Configuration.new
83
+ end
84
+
85
+ private
86
+
87
+ # グローバル設定 → 呼び出し時オプションの順にマージしてargvにする。
88
+ def argv_for(options)
89
+ Options.to_argv(config.to_h.merge(options))
90
+ end
91
+
92
+ # `server_url`があればサーバへ委譲する。タイムアウトも同じ順でマージする。
93
+ def server_client(options)
94
+ merged = config.to_h.merge(options)
95
+ url = merged[:server_url]
96
+ return nil if url.nil? || url.to_s.empty?
97
+
98
+ ServerClient.new(
99
+ url,
100
+ open_timeout: merged[:server_open_timeout],
101
+ read_timeout: merged[:server_read_timeout]
102
+ )
103
+ end
104
+
105
+ # ブロックへ1回に渡すバイト数の目安(ローカル変換のみ)。
106
+ def chunk_size(options)
107
+ value = config.to_h.merge(options)[:chunk_size]
108
+ value.nil? ? DEFAULT_CHUNK_SIZE : Integer(value)
109
+ end
110
+
111
+ # サーバへ渡すオプション。流し込まれた既定値は外す
112
+ # (Rails向けの`base_url`・`allow`はローカルのファイル解決のための
113
+ # 既定値で、サーバモードではリクエストから指定できず400になる。
114
+ # 明示的に設定した値はそのまま送り、可否はサーバに判断させる)。
115
+ def server_options(options)
116
+ config.to_h(with_defaults: false).merge(options)
117
+ end
118
+ end
119
+ end
120
+
121
+ 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.1.0
5
+ platform: aarch64-linux
6
+ authors:
7
+ - yo_waka
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-08-08 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: |
14
+ wkhtmltopdfの後継として、Chromium/WebKit/Geckoに依存しないRust製の
15
+ HTML→PDFレンダリングエンジンをRubyから使うためのバインディング。
16
+ Railsからはwicked_pdf互換のレンダラ(`render pdf: "invoice"`)で使える。
17
+ email:
18
+ - y.wakahara@gmail.com
19
+ executables: []
20
+ extensions: []
21
+ extra_rdoc_files: []
22
+ files:
23
+ - LICENSE
24
+ - README.md
25
+ - lib/sghtmltopdf.rb
26
+ - lib/sghtmltopdf/3.0/sghtmltopdf.so
27
+ - lib/sghtmltopdf/3.1/sghtmltopdf.so
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.0'
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: Chromium/WebKit/Geckoに依存しないHTML→PDFレンダラー
69
+ test_files: []