sghtmltopdf 0.3.0 → 0.4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: de56b0b878dc4ce3c0150d6f2b51e506820b28cd2bcc0577b57b8335ec6e3a90
4
- data.tar.gz: 0f91a87de49fb27ea32d627873be0c4fe743c54ea38f69ce94f696c2d5791a17
3
+ metadata.gz: 0f317db9c1a48c59df592601e2246186da7539007f19859be3ebbf6c057283bb
4
+ data.tar.gz: 10d371ed48aa9b6772c840a75d4cc9e456468c783cae83ee95a0b73588b345a1
5
5
  SHA512:
6
- metadata.gz: 89dd71b47c89519290464731545bddee9fc7aa0364f8809db73478eec7a451834f453fce779698cad431a7d63aca40231c7ffd0e33dd4b543be0804d826bf0a5
7
- data.tar.gz: 5f5444730f3d61dcc1efbf8c0925ca41b1868203460cbc6c53b9da6dae2c88c0e30d341df578313c276e69a574d4bd7d24d1f0ec32b7252b0beb44a76a3026c1
6
+ metadata.gz: ba289e596824c8cbc3a259bd1adfd9397e47c333cdbcefeff1d8e33ae512c64ec197a67d98f5f90a35f46cefc833b837b0348e6625dd69e03ff732a717844d3b
7
+ data.tar.gz: 9f6f870dee5e361e175d19d778d3129d416814cb7c7ae0092afe95e59f6469a1f43cbe24a5fa0e9a719c39ee406cb157cbf50285a64a67760f761705558dd9e1
data/Cargo.lock CHANGED
@@ -1050,7 +1050,7 @@ dependencies = [
1050
1050
 
1051
1051
  [[package]]
1052
1052
  name = "sghtmltopdf"
1053
- version = "0.3.0"
1053
+ version = "0.4.0"
1054
1054
  dependencies = [
1055
1055
  "magnus",
1056
1056
  "rb-sys",
@@ -1059,7 +1059,7 @@ dependencies = [
1059
1059
 
1060
1060
  [[package]]
1061
1061
  name = "sghtmltopdf-core"
1062
- version = "0.3.0"
1062
+ version = "0.4.0"
1063
1063
  dependencies = [
1064
1064
  "base64",
1065
1065
  "clap",
data/README.md CHANGED
@@ -68,14 +68,19 @@ Converter keys are flat CLI flag names, so wicked_pdf's nested `margin: {top: 10
68
68
 
69
69
  ### Assets
70
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:
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
73
 
74
74
  ```erb
75
75
  <%= sghtmltopdf_stylesheet_link_tag "pdf" %>
76
76
  <%= sghtmltopdf_image_tag "logo.png" %>
77
77
  ```
78
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
+
79
84
  ### Streaming the response
80
85
 
81
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:
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "sghtmltopdf"
3
- version = "0.3.0"
3
+ version = "0.4.0"
4
4
  edition = "2021"
5
5
  publish = false
6
6
  description = "Ruby binding for sghtmltopdf (see bindings/ruby)"
@@ -24,12 +24,12 @@ module Sghtmltopdf
24
24
  end
25
25
 
26
26
  def [](key)
27
- key = key.to_sym
27
+ key = Options.canonical_key(key)
28
28
  @options.key?(key) ? @options[key] : @defaults[key]
29
29
  end
30
30
 
31
31
  def []=(key, value)
32
- @options[key.to_sym] = value
32
+ @options[Options.canonical_key(key)] = value
33
33
  end
34
34
 
35
35
  # @param with_defaults [Boolean] 流し込まれた既定値を含めるか。
@@ -43,7 +43,7 @@ module Sghtmltopdf
43
43
  # 既定値を流し込む。Railtieが Rails向けの既定値を入れるのに使う。
44
44
  # 明示的に設定された値より弱い(順序に関係なく`[]=`が勝つ)。
45
45
  def apply_defaults(defaults)
46
- defaults.each { |key, value| @defaults[key.to_sym] = value }
46
+ defaults.each { |key, value| @defaults[Options.canonical_key(key)] = value }
47
47
  self
48
48
  end
49
49
 
@@ -17,8 +17,26 @@ module Sghtmltopdf
17
17
  # クエリにも出さない。
18
18
  TRANSPORT_KEYS = %i[server_url server_open_timeout server_read_timeout chunk_size].freeze
19
19
 
20
+ # 別名のキー(値は正規名)。CLIは`--allow`を`--allow-path`の別名として
21
+ # 受けるが、Ruby側は2つのキーのまま持ち回ってはいけない。既定が一方の
22
+ # キー、呼び出し時の指定がもう一方のキーだと、ハッシュのマージでは
23
+ # 上書きにならず両方がargvへ出てしまう(同じフラグの繰り返しは
24
+ # 「置き換え」ではなく「合併」の意味になる)。
25
+ ALIAS_KEYS = {allow: :allow_path}.freeze
26
+
20
27
  module_function
21
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
+
22
40
  # @param options [Hash] Rubyのオプションハッシュ
23
41
  # @return [Array<String>] clapへ渡す引数列
24
42
  def to_argv(options)
@@ -113,7 +131,8 @@ module Sghtmltopdf
113
131
  # 変換オプションだけを、渡された順にペアとして列挙する。
114
132
  def each_pair(options, &block)
115
133
  options.each do |key, value|
116
- next if TRANSPORT_KEYS.include?(key.to_sym)
134
+ key = canonical_key(key)
135
+ next if TRANSPORT_KEYS.include?(key)
117
136
 
118
137
  pairs_for(key, value).each(&block)
119
138
  end
@@ -6,16 +6,38 @@ require_relative "view_helpers"
6
6
 
7
7
  module Sghtmltopdf
8
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")
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?
13
27
  defaults[:base_url] = public_dir if File.directory?(public_dir)
14
28
  defaults
15
29
  end
16
30
 
31
+ # 読むのはinitializerの中ではなく`after_initialize`。パイプラインが
32
+ # `config.assets.paths`を埋めるのは自分のinitializer(Propshaftなら
33
+ # `propshaft.append_assets_path`)で、そちらの方が後に走るため。
34
+ #
35
+ # `config/initializers`より後になるが、`apply_defaults`は明示的に設定した
36
+ # 値より常に弱いので、ユーザーの設定を踏むことはない。
17
37
  initializer "sghtmltopdf.defaults" do |app|
18
- Sghtmltopdf.config.apply_defaults(Sghtmltopdf::Railtie.default_options(app.root))
38
+ app.config.after_initialize do
39
+ Sghtmltopdf.config.apply_defaults(Sghtmltopdf::Railtie.default_options(app))
40
+ end
19
41
  end
20
42
 
21
43
  initializer "sghtmltopdf.renderer" do
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Sghtmltopdf
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -1,65 +1,280 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Sghtmltopdf
4
- # Action View用のヘルパ。
4
+ # Action View helpers.
5
5
  #
6
- # PDFのレンダリングはHTTPサーバを介さないため、`/assets/…`のようなURL
7
- # ローカルファイルとして解決される(`--base-url`の既定は
8
- # `Rails.root/public`。[Railtie.default_options])。precompile済みの
9
- # 本番環境ではこれで素の`stylesheet_link_tag`もそのまま動くが、開発環境の
10
- # ようにアセットがまだ`public/`へ書き出されていない場合は解決できない。
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.
11
13
  #
12
- # そこで、
14
+ # Hence helpers that look the asset up in the pipeline and hand the engine
15
+ # something it can actually read:
13
16
  #
14
17
  # <%= sghtmltopdf_stylesheet_link_tag "pdf" %>
18
+ # <%= sghtmltopdf_image_tag "logo.png" %>
15
19
  #
16
- # のようにCSSの中身を`<style>`へ展開するヘルパを用意する(wicked_pdf
17
- # `wicked_pdf_stylesheet_link_tag`に相当)
20
+ # (the counterparts of wicked_pdf's `wicked_pdf_stylesheet_link_tag` and
21
+ # `wicked_pdf_image_tag`).
18
22
  module ViewHelpers
19
- # アセットのローカルファイルパスを返す。見つからなければ`nil`。
23
+ # Local file path of an asset, or `nil` when it cannot be found.
20
24
  #
21
- # 1. `public/`配下(precompile済み。本番環境)
22
- # 2. アセットパイプラインのロードパス(開発環境。Propshaft/Sprockets)
25
+ # Looks in
23
26
  #
24
- # の順に探す。パイプラインの参照はどちらのgemにも依存しないよう
25
- # `respond_to?`で分岐している(best effort)
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).
26
32
  def sghtmltopdf_asset_path(source)
27
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)
28
38
  return path if path.start_with?("/") && File.file?(path)
29
39
 
30
40
  from_public_dir(path) || from_asset_pipeline(path)
31
41
  end
32
42
 
33
- # CSSの中身を`<style>`へ展開する。複数指定でき、見つからないものは
34
- # 黙って飛ばす(PDF生成そのものは止めない)
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].
35
49
  def sghtmltopdf_stylesheet_link_tag(*sources)
36
50
  css = sources.flatten.filter_map do |source|
37
51
  path = sghtmltopdf_asset_path(with_extension(source, ".css"))
38
- File.read(path) if path
52
+ inline_stylesheet(path) if path
39
53
  end
40
54
  return "".html_safe if css.empty?
41
55
 
42
56
  content_tag(:style, css.join("\n").html_safe, type: "text/css")
43
57
  end
44
58
 
45
- # `image_tag`のsrcをローカルファイルパスへ差し替える。
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.
46
76
  def sghtmltopdf_image_tag(source, options = {})
47
- image_tag(sghtmltopdf_asset_path(source) || 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)
48
92
  end
49
93
 
50
94
  private
51
95
 
52
- # `asset_path`が返すURL(asset_hostが付くこともある)からパス部分だけを
53
- # 取り出し、`public/`配下の実ファイルへ対応付ける。
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/`.
54
256
  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
257
+ relative = asset_url_for(source).to_s.sub(%r{\Ahttps?://[^/]+}, "").split(/[?#]/).first.to_s
57
258
  return nil if relative.empty?
58
259
 
59
260
  candidate = File.join(::Rails.public_path.to_s, relative)
60
261
  File.file?(candidate) ? candidate : nil
61
262
  end
62
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
+
63
278
  def from_asset_pipeline(source)
64
279
  assets = ::Rails.application.try(:assets)
65
280
  return nil if assets.nil?
@@ -77,6 +292,111 @@ module Sghtmltopdf
77
292
  nil
78
293
  end
79
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
+
80
400
  def with_extension(source, extension)
81
401
  name = source.to_s
82
402
  name.end_with?(extension) ? name : "#{name}#{extension}"
data/lib/sghtmltopdf.rb CHANGED
@@ -47,6 +47,7 @@ module Sghtmltopdf
47
47
  # 1回に渡すバイト数の目安は`chunk_size:`で変えられる(既定64KiB。
48
48
  # ローカル変換のみ。小さくするとGVLの取り直しが増える)。
49
49
  def render(html, **options, &block)
50
+ options = Options.canonicalize(options)
50
51
  client = server_client(options)
51
52
  return client.render(html.to_s, server_options(options), &block) if client
52
53
  return Native.render(html.to_s, argv_for(options)) if block.nil?
@@ -60,6 +61,7 @@ module Sghtmltopdf
60
61
  # 一時ファイルへ書いて成功時だけrenameするため、途中で失敗しても
61
62
  # 壊れたPDFが出力先に残らない(サーバへ委譲する場合も同じ)。
62
63
  def render_to_file(html, path, **options)
64
+ options = Options.canonicalize(options)
63
65
  client = server_client(options)
64
66
  return client.render_to_file(html.to_s, server_options(options), path.to_s) if client
65
67
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sghtmltopdf
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - yo_waka