sghtmltopdf 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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,66 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sghtmltopdf
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - yo_waka
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: |
13
+ wkhtmltopdfの後継として、Chromium/WebKit/Geckoに依存しないRust製の
14
+ HTML→PDFレンダリングエンジンをRubyから使うためのバインディング。
15
+ Railsからはwicked_pdf互換のレンダラ(`render pdf: "invoice"`)で使える。
16
+ email:
17
+ - y.wakahara@gmail.com
18
+ executables: []
19
+ extensions:
20
+ - ext/sghtmltopdf/extconf.rb
21
+ extra_rdoc_files: []
22
+ files:
23
+ - Cargo.lock
24
+ - Cargo.toml
25
+ - LICENSE
26
+ - README.md
27
+ - ext/sghtmltopdf/Cargo.toml
28
+ - ext/sghtmltopdf/extconf.rb
29
+ - ext/sghtmltopdf/src/callback_sink.rs
30
+ - ext/sghtmltopdf/src/errors.rs
31
+ - ext/sghtmltopdf/src/gvl.rs
32
+ - ext/sghtmltopdf/src/lib.rs
33
+ - lib/sghtmltopdf.rb
34
+ - lib/sghtmltopdf/configuration.rb
35
+ - lib/sghtmltopdf/options.rb
36
+ - lib/sghtmltopdf/railtie.rb
37
+ - lib/sghtmltopdf/renderer.rb
38
+ - lib/sghtmltopdf/server_client.rb
39
+ - lib/sghtmltopdf/version.rb
40
+ - lib/sghtmltopdf/view_helpers.rb
41
+ homepage: https://github.com/waka/sghtmltopdf
42
+ licenses:
43
+ - MIT
44
+ metadata:
45
+ source_code_uri: https://github.com/waka/sghtmltopdf
46
+ changelog_uri: https://github.com/waka/sghtmltopdf/blob/main/CHANGELOG.md
47
+ bug_tracker_uri: https://github.com/waka/sghtmltopdf/issues
48
+ rubygems_mfa_required: 'true'
49
+ rdoc_options: []
50
+ require_paths:
51
+ - lib
52
+ required_ruby_version: !ruby/object:Gem::Requirement
53
+ requirements:
54
+ - - ">="
55
+ - !ruby/object:Gem::Version
56
+ version: 3.2.0
57
+ required_rubygems_version: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ requirements: []
63
+ rubygems_version: 3.6.9
64
+ specification_version: 4
65
+ summary: Chromium/WebKit/Geckoに依存しないHTML→PDFレンダラー
66
+ test_files: []